Compare commits

...
Author SHA1 Message Date
jheapsandClaude Opus 5 1af82dfddf Call a bet a bet and a raise a raise
The bar decided which word to use from what the acting player still owes. That gets
the big blind's option backwards -- they owe nothing, so it offered "Bet", but their
own blind is a live bet and putting more in is a raise. And it gets the other half
wrong too: open the pad to make a first bet on the flop and the confirm button said
"Raise to", then wrote "Ann's raise to $10" into the undo control for it.

  preflop, action back to the big blind
    was:  Fold | Check | Bet        pad: "Raise to"
    now:  Fold | Check | Raise      pad: "Raise to"
  flop, nobody has bet
    was:  Fold | Check | Bet        pad: "Raise to"   undo: "Ann's raise to $10"
    now:  Fold | Check | Bet        pad: "Bet"        undo: "Ann's bet of $10"

The arithmetic was always right -- minRaiseTo returns two big blinds for the big
blind's option and one for an opening bet, and I checked both before touching
anything. This is only what it is called. For a tool a dealer announces the action
from, that is not a small thing: a dealer who says "bet" when the big blind puts more
in gets corrected by the table.

The test is whether there is a live bet to raise, not whether this player owes
anything, and it is one helper rather than a condition written out five times.

The warnings follow the same word. Under-minimum with a bet in front of you leaves
you calling it; with an empty pot there is nothing to call:

  "$3 is under the $4 minimum. A legal raise is $4 or more -- otherwise it's a
   call, or all-in."
  "$1 is under the $2 minimum. A legal bet is $2 or more -- otherwise it's a
   check, or all-in."

Also the field's screen-reader label, which announced "Raise to amount" for a bet.

8 tests (206 total), including a table playing without blinds -- which opens with a
bet, and is the one preflop case where the old condition happened to be right.

Driven through the UI at seven viewports in both spots: every label correct, contrast
clean, no console errors.

One thing found and deliberately not touched. Fuzzing 40 hands with random actions
and rebuys -- chips stayed conserved to the cent throughout, no negative stacks, and
the acting seat was always a seat that could act -- turned up one disagreement:
isStreetComplete returns false during an all-in run-out where one player still has
chips and hasn't acted, while actingSeatIndex is correctly null. It has no symptom,
because the bar picks its layout from actingSeatIndex and shows "Deal Turn" as it
should. Changing a betting-round predicate with no observable fault behind it is a
worse trade than leaving it written down.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 13:23:02 -06:00
jheapsandClaude Opus 5 2cc2c66976 Deal around a player who busted and hasn't rebought
The most ordinary thing that happens in a home game: somebody stacks off, and they
don't buy back in that second. The table dealt them in anyway, and the result was a
hand nobody could finish.

Three players, two of them busted:

  bar before dealing:
    "Pot $0 | 3 players | All-in - running out the board (3 players)
     | Cal posts $1, Ann posts $2 | Post blinds & deal"
  after dealing:
    Cal posted nothing -- no chips to post with
    Ann posted $2 and was asked to check or bet against her own money
    liveSeats 3, isStreetComplete false, isHandOver false

Every line of that is wrong. It announced a board being run out over a felt with no
cards on it, promised a small blind from a seat that couldn't post one, took it from
nobody, and then sat on a hand with no way forward.

So a seat with no chips and nothing in the pot is dealt around. Their seat, name and
buy-in record are untouched -- sat out, not folded, because they didn't muck anything
-- and adding chips puts them straight back in the next hand.

  four players, Ben busted, button on Ann:
    "3 players | Cal posts $1, Dee posts $2
     | Ben has no chips - sitting out this hand. | Post blinds & deal"
    -> Ben inHand false, folded false; blinds exactly as promised; Ann to act

Said before it happens, for the same reason the button move is: a dealer shouldn't
find out from the felt. And the preview computes it on a view of the table rather
than by changing it, so the promise and the deal ask the same question -- the fault
these two lines exist to prevent.

When it can't be dealt at all, it says who has to buy in rather than pretending it's
a seating problem:

  "Only Ann has chips - Ben and Cal need to buy in."
  "Nobody at the table has chips left. Add a buy-in to deal."

Two smaller things fall out. The run-out banner is now gated on the betting actually
being open -- before the deal, a table of busted players is indistinguishable from a
table of all-ins to that test. And the bar's player count reads the table as it will
be dealt, so someone waiting on a rebuy isn't announced as one of the players.

Not the same as all-in, and the code says so: an all-in player has money riding on
the hand. A seat with no stack recorded isn't broke either -- those fall back to the
table's starting stack.

13 tests (198 total), and 5 of them fail with the sit-out and the run-out guard taken
back out.

Driven through the UI at seven viewports in both states: layout and contrast clean
everywhere but the landscape phone already known from #63, and no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 13:08:21 -06:00
jheapsandClaude Opus 5 a92a389245 Work out who pays whom
I went looking for what home-game dealers actually struggle with rather than reading
the code again, and the answer that kept coming up was the end of the night: hosts
reach for a separate app to finish a game "knowing exactly who owes whom in seconds
rather than minutes".

This tool already had every number that answer needs. The standings panel knows what
each player is up or down -- chips in front of them less the cash they handed over --
and then stops. Which leaves the dealer with six signed numbers at 2am and the actual
job still to do. That last step is the part people get wrong, not the poker.

  Ann  +$75    Dee  +$110      Fay -> Dee   $70
  Ben  -$10    Eve   -$40      Cal -> Dee   $40
  Cal  -$65    Fay   -$70      Cal -> Ann   $25
                               Eve -> Ann   $40
                               Ben -> Ann   $10

Largest debt against largest win, repeatedly. Greedy rather than optimal, which is
the right trade here: the true minimum number of transfers is NP-hard, while this
needs at most one payment fewer than there are people and each one it emits clears
somebody out of the list entirely. A dealer wants a short list to read down, not a
proof. Payers are the outer loop so everything one person owes prints together --
the list gets read out one player at a time.

All of it in integer cents. A settlement that has to come out exactly right is the
last place to let a float land on 41.99999999.

When the chips on the table don't match the cash taken

Then the payments can't all be made, and that is reported rather than quietly spread
across them. It is never rounding -- the arithmetic is integer cents -- so it is
always what the bank check sitting right above it is already warning about: a rebuy
nobody wrote down, or a miscount. Inventing a transfer to make the columns agree is
how a real person ends up short.

  $100 of winnings has nothing to pay it -- there are more chips on the table than
  cash was taken for. Fix that above before anyone cashes out.

The payment list goes into the CSV export too, under the standings it came from,
with the caveat if there is one. Whoever opens that file wants the answer, not to
redo the arithmetic in a spreadsheet.

13 tests (185 total): everyone ends up square, at most n-1 payments, a payer's lines
stay together, cents survive, nobody pays themselves, no payment is ever for zero,
both directions of an unbalanced bank, and one that settles a real table's standings
rather than hand-made numbers.

Driven through the UI at six viewports, balanced and not: clean layout audit and
contrast everywhere but the landscape phone already known from #63. Checked with
26-character names and seven-figure stacks on a 360px screen -- names wrap, amounts
stay whole and right-aligned, nothing overflows.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:52:56 -06:00
jheapsandClaude Opus 5 3d1ce1f45c Keep the table above the action bar on wide screens
The bar publishes its height so the layout can stay clear of it. Only the phone and
landscape rules ever read it. The wide layout sized the felt from its own width -- a
box 72% as tall as it is wide -- and so had no idea what was underneath it.

That was survivable while the bar was a constant. It isn't: it grows from 117px to
172px the moment a hand needs a side pot, which is exactly the hand where the dealer
most needs to see who is still in. Measured with two side pots, six seats, the bottom
seat sat this far behind the bar:

  1280x900    22px      1600x1000   54px
  768x1024    23px      1024x768    clear
  360x640     39px      390x844     clear

The first attempt at this didn't work and the failure is worth recording: the wrap was
a padding-bottom: 72% box, and percentage padding resolves against the containing
block's width rather than the element's own. Capping the width narrowed the felt from
822px to 746px and left it exactly 617px tall, so the seat stayed where it was. It is
an aspect-ratio box now, and the shape is stated once as --feltAspect so the height
and the cap both derive from it.

  1280x900   felt 857x617 -> 746x537   seat 22px behind the bar -> 51px clear
  1600x1000       ...760 -> 885x637              54px behind -> 59px clear
  768x1024   felt 689x689 -> 603x603              23px behind -> 56px clear

Six-seat tables at 1280x900, 1600x1000, 1024x768 and 768x1024 all go from one covered
seat to a clean audit.

Not applied below 700px. The narrow layout already had an answer -- it reserves the
bar's height as scroll room under the table, and a phone is a thing you scroll -- and
capping there was measured and rejected: at 360x640 it took the felt from 321px to
226px, which fixed the six-seat ring and broke the nine-seat one, two covered seats
traded for two seats whose centre landed on a neighbour.

Landscape phones opt out too. They set their own height from the bar already and are
sitting on their 7rem floor; 844x390 and 667x375 still have the bottom seat in the
bar, and nothing about a 112px-tall table with 73px seats can fix that.

The seat overhang is a constant rather than something measured off the layout it is
about to change -- a seat is ~73px tall at every width -- so this settles in one pass
instead of asking a different question each time it shrinks the felt.

172 tests still passing; no console errors; contrast clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:35:15 -06:00
jheapsandClaude Opus 5 29188d6049 Say ALL IN on the felt
A player with nothing behind had a blank where their stack goes. So did a seat whose
stack nobody had entered. On a table with side pots that is the one distinction the
dealer is working from -- who can still be bet at, and who is already in for
everything they have -- and the felt drew the two states identically.

  Ann  $40  $160     has chips
  Ben  $40           all-in for 40
  Cal  $40  $160     has chips
  Dee  $12           all-in for 12

The action bar knew: it had already built "Main pot $48, Ann/Ben/Cal/Dee" and
"Side pot 1 $84, Ann/Ben/Cal". The table it sits under didn't say a word.

Betting.isAllIn is the same stack test nextToAct uses to pass over a player, which is
the point: the seat the felt marks is exactly the seat the dealer will never be asked
to act on again. There's a test asserting that equivalence rather than restating the
condition.

The second half of the condition matters. A seat with an empty stack that has put
nothing in is a busted player who shouldn't have been dealt in, not a player who
moved all-in, so it isn't marked.

"ALL IN" in gold where the stack was, and a warmer border on the seat -- deliberately
quieter than the acting-seat ring, because all-in is a standing fact about a player
rather than a request for attention.

9 tests (172 total). They fail on a naive condition: dropping the committed-chips and
folded checks fails 4 of them.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:34:53 -06:00
jheapsandClaude Opus 5 4c1ac222a8 Give Enter and Space back to the control that has focus
Found while checking the keyboard could still reach the dealer button: it could
reach it and then couldn't press it.

Enter and Space were bound to "advance" for the whole document, with an exception
only for text fields. Those two keys are how a keyboard presses the control it is
sitting on, so every button on the page lost them -- and not to nothing. Measured:

  Tab to Resolve Pot, press Enter   ->  the table dealt the next hand
  Tab to Resolve Pot, press Space   ->  Resolve Pot, correctly
  Tab to a dealer button, Enter     ->  nothing at all

The inconsistency is the shortcut handler's return value. It calls preventDefault
only when it handled the key, so when "advance" applied it ran instead of the button,
and when it didn't apply the browser's own activation got through. A control that
does something else every other press is worse than one that does nothing.

Enter and Space now go to a focused button, [role=button], link or summary, and the
shortcut only claims them when nothing pressable has focus. The letters stay live
with a button focused -- a button does nothing with "f", and a dealer whose hand is
on the keys shouldn't lose them to a stray focus ring.

Space still advances the street: dealt, f, f, c, c, Space -> flop. The action bar
rebuilds itself after every press, so focus falls back to the body and the dealer's
Space is never quietly aimed at the Fold they just clicked.

11 tests (163 total), and they fail on the old handler -- 4 of them, exactly the
Enter/Space-on-a-control cases.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:16:15 -06:00
jheapsandClaude Opus 5 784315f2ea Put one dealer button on the table
A poker table has one dealer button. This one drew nine.

Every seat carried a puck: the real one in gold, and eight ghosts at 0.32 opacity
that existed to be pressed. Measured on a nine-seat table -- eight pale discs, all
eight showing the letter D, one of them the button. Finding it meant comparing nine
identical shapes for the one that's gold, when it should be the only thing of its
kind on the felt.

The ghosts also sat on their neighbours. Every remaining "something important is
covered" offender on a crowded table was a live seat covered by the seat-next-door's
ghost puck: 844x390, 915x412, 360x640 and 640x360 each reported one, and they were
all the same thing.

And a screen reader heard nine buttons named "D".

So a seat without the button doesn't draw one. Hovering a seat fades its slot in --
a preview of where the button would go, one click as before -- and on touch, where
there is no hover, "Give the button" has been in the seat's long-press menu all
along. Keyboard reaches it by focus, where the slot appears too.

pointer-events matters as much as the opacity: an invisible puck that still took
taps would be worse than a visible one.

  visible pucks    9 -> 1 at every size
  layout offenders 915x412 1->0, 360x640 1->0, 844x390 3->2, 640x360 3->2
  clean viewports  4 of 9 -> 6 of 9
  labels           nine "D" -> "Ann has the dealer button" and
                              "Give the dealer button to Ben"

The audit now checks it, because this is a rule about the table rather than about
CSS: one puck drawn, and no two announcing themselves the same way. Verified against
the old behaviour put back -- it reports 9 and 9.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 12:16:00 -06:00
jheapsandClaude Opus 5 0c6cd55951 Let the landscape table use the width it has
Last round I left a residual and said it wanted its own fix: on a phone held
sideways, seats overlapped so badly that a seat's centre belonged to the seat next
door. Here's the cause.

The landscape rule capped the felt's width by its height to hold the portrait
aspect ratio. At 640x360 that produced a table 135px wide inside a 601px column --
22% of the screen -- with 366px of rim for nine seats needing 662px. Adjacent seat
centres ended up 13px apart for seats rendering 74px wide.

Landscape has width and no height, so the table now takes the width it has and is
only as tall as the room left over. A wide ellipse, which is what a real poker
table is anyway.

  640x360   felt 135x97  ->  601x121   rim 366 -> 1263   centres 13px -> 33px
  844x390   felt 176x127 ->  765x151   rim 480 -> 1605   centres 37px -> 59px
  915x412   felt 207x149 ->  492x173   rim 563 -> 1106   centres 44px -> 55px

Seat ownership went from 8 seats wrong at 640x360 and 2 at 844x390 to zero at
844x390, zero at 915x412, and two at the very smallest. Nine of eleven sizes clean,
portrait and desktop untouched.

Two things I tried and threw away

Spacing seats by arc length instead of by angle. It sounds obviously right -- even
distance around the rim rather than even angle -- and it made things worse: 640x360
went from 2 wrong to 3, closest centres 33px to 21px, and even the desktop table
dropped from 180px to 155px. Even arc spacing packs seats through the tight turns
at the ends of an ellipse, where a fixed distance along the rim covers much less
straight-line distance. Equal angles happen to separate them better. Reverted.

Shrinking seats on the tightest landscape phones. Took the rendered seat from 74px
to 64px and moved closest centres not at all -- still 33px -- while the covered
count at 844x390 went from 3 to 4. A change that measures as nothing but costs CSS
and legibility isn't worth keeping. Reverted.

So this is one CSS rule, which is all the measurements supported.

What's left

640x360 and 667x375 still have two seats whose centre lands on a neighbour. Nine
seats on a ring 121px tall is a genuine physical constraint rather than a layout
mistake, and both remaining fixes I tried made it worse rather than better. It is
reported by the audit rather than hidden, and a dealer on a 360px-tall screen has
the seat menu and the action bar for everything that matters.

152 tests passing; contrast clean at every size; dealing still works in landscape.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 10:39:45 -06:00
jheapsandClaude Opus 5 e01d8e7c49 Tapping a seat gives you that seat
Went to audit the seat editor and couldn't open it: Playwright refused to click the
middle of seat 1 because seat 9's dealer chip was sitting there. On a nine-seat
table, tapping the centre of one player handed the button to another.

I shipped that. The enlarged chip hit area grew outward from a chip already hung
over its seat's corner at -10px/-10px -- and on an oval, a seat's corner is the
seat next to it. Measured on the nine-seat table:

  seat 1  10 of 49 sampled points taken by seat 9's chip, including its centre
  seat 4   9 of 49 taken by seat 5's chip
  seat 5   3 of 49 taken by seat 6's chip

I claimed at the time that no seat tap was stolen, and I had checked -- with an
audit that exempted anything inside a `.seat` as harmless crowding. The thief was
inside a seat. The check walked straight past it.

Two fixes, and the second matters more

The chip sits inside its seat now rather than on the rim, and its hit area grows
inward over its own seat, which is a big target that can spare a corner. Padding
inward alone wasn't enough -- I tried it and the numbers didn't move, because the
chip's own box was the problem, not just the pad. Isolating that took disabling the
pad and then the overhang separately: pad off gave 2 seats affected, overhang off
gave zero.

And the audit now asks whether a seat's centre belongs to that seat, as its own
check. The occlusion check couldn't answer it: it was built for overlays on top of
the table, and this was two pieces of the table fighting over a tap. It exempts a
neighbour's body -- seats do overlap by design -- but not a neighbour's controls,
which was the exact hole.

Seven of eight sizes now have no seat whose centre goes elsewhere, and all four
seats I tap open their own editor.

What's left, stated rather than buried

At 360x640 and 375x667 one seat still loses a single sample point of its rim to a
neighbour's chip. Centres are correct; this is the rim of a crowded oval.

At 844x390 two seats have centres belonging to the seat next door -- and those are
seat bodies, not chips. Nine seats on a 163px-tall landscape table overlap almost
completely. That's the landscape felt sizing, it predates all of this, and it wants
fixing on its own terms rather than being folded in here.

Both are now reported by the audit instead of hidden by it, which is the part I
actually trust.

152 tests still passing; contrast clean at every size.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 10:24:14 -06:00
jheapsandClaude Opus 5 c77161c3e9 Stop the last two things the table did behind the dealer's back
Your bug report was about a category, not one button, so I went looking for the
rest of the guessing rather than picking a new topic. Two left, both about the
button -- and one of them was in the fix I'd just shipped.

The deal preview lied

The prompt added last time promises who will post. With the button parked on an
empty chair it promised the wrong pair: it read the button where it currently sat,
while dealing relocates the button onto a player first and only then works out the
blinds.

  three players in seats 2-4, button parked on empty seat 1
    preview said:  s2 posts $1, s3 posts $2
    dealing gave:  s3 posts $1, s4 posts $2

Exactly the fault you reported -- the display and the thing it describes computed
from different moments -- inside the change meant to remove it. buttonSeatForDeal
answers where the button will be without moving it, so the preview asks the same
question the deal will answer. And the move is now stated before it happens:
"Button moves to Ben - seat 1 is empty."

Moving the button after the deal moved the blinds silently

Once the blinds are posted, moving the button takes them back and re-posts them,
because otherwise the badges and the money disagree. That's the right behaviour
and it was completely silent. The toast says so now: "Button on Dee - blinds taken
back and re-posted."

It also only does it when there is actually something posted to move. Before the
deal there is nothing committed, so passing the button is just moving a marker --
which is the whole point of the setup step.

Checked and deliberately left alone

Streets still advance one press at a time during an all-in run-out, which is right
-- the dealer is physically dealing the board between them. A player who joins
mid-hand is still marked folded for that hand and plays the next one. The
base-pot-amount shorthand still stands in for "everyone antes the same". Nothing
mutates state during a render.

4 tests (152 total): the preview matches what dealing produces with the button on
an empty chair, asking where the button will be doesn't move it, a button already
on a player is left alone, and an empty table doesn't break the question.

Driven through the UI: the prompt warns about the move and then the deal matches
it exactly; moving the button after the deal announces the re-post; and the blinds
survive being passed around three seats with the chip count fixed at 400. Layout
and contrast audits clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 10:06:58 -06:00
jheapsandClaude Opus 5 843ef9cd42 Wait to be told to deal, instead of guessing
Dealing and betting were one button. "Next Hand" moved the button and posted the
blinds in the same breath, so the moment between hands -- the one where people are
still sitting down, standing up and arguing about whose turn it is -- was spent
with money already committed.

That's where the guessing came from. The blind badges recompute from `inHand`
every render, but the posted chips are frozen from whenever beginHand ran, so the
two drift apart the instant the table changes:

  seat a fourth player after Next Hand
    badges said sb=s3 bb=s4, money on the table was s1=$2 s3=$1
    s4 was shown as the big blind having posted nothing

  take out the big blind after Next Hand
    badges moved to bb=s1, s4's $2 stayed in the pot, s1 had posted nothing

  press Next Hand twice
    burned a hand, moved the button twice, chips went 300 -> 297

Now the dealer says when. Next Hand advances the button and clears the table, and
stops. The bar shows who will post -- "Ben posts $1, Cal posts $2" -- and waits on
"Post blinds & deal". Enter does it too, so a dealer can carry a hand from setup
through the streets to showdown without reaching for the trackpad.

Until that press nothing is committed, so setting the table is free: seat someone,
take someone out, pass the button around, name the blinds by hand. The preview
follows every change and the chip count never moves. Settling a pot ends the hand
rather than starting the next one, for the same reason -- people cash out between
hands.

The preview is honest about what it can't do: fewer than two players and there's
no button at all, just what's missing.

A table saved before this existed has no flag, and guessing "not dealt" would
offer to deal over a live hand. Anything already committed, or anyone already on
the clock, means the betting was open when the tab was closed.

11 tests (148 total): the table waits, no money moves until the deal, seating and
unseating and passing the button all cost nothing, what the preview promises is
what gets posted, and a mid-hand save comes back mid-hand.

Driven through the real UI: seat a player and move the button while waiting, watch
the preview follow with the chip count fixed at 400, deal, play it out, settle, and
land back in setup. Triple-tapping Next Hand now costs nothing but button position
-- it used to cost a hand and three chips. Layout and contrast audits clean in both
the new setup state and after dealing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 09:51:43 -06:00
jheaps e5d5b515ba Merge pull request 'Hold'em dealer: nine polish rounds released together' (#57) from feature/casino-holdem-acting-seat into master
Build and Deploy / build-deploy (push) Successful in 1m22s
Releases #49-#57 in one go: pot safety, the banker's cash reconciliation, the short all-in rule, the standings name column, the layout and contrast audits, dealer puck hit area, illegal-raise warnings, and the acting-seat highlight.
2026-09-01 09:31:52 -06:00
jheaps a14087fa7d Merge pull request 'Stop rebuilding the hand log after every fold, call and raise' (#48) from feature/casino-holdem-render-perf into master
Build and Deploy / build-deploy (push) Successful in 1m27s
2026-09-01 09:30:13 -06:00
jheaps 76f1952d61 Merge pull request 'Make the overlays usable from a keyboard' (#47) from feature/casino-holdem-keyboard-focus into master
Build and Deploy / build-deploy (push) Canceled after 10s
2026-09-01 09:30:07 -06:00
jheaps f464e6f2e2 Merge pull request 'Make big numbers readable, and make the export add up' (#46) from feature/casino-holdem-resilience into master
Build and Deploy / build-deploy (push) Canceled after 5s
2026-09-01 09:30:02 -06:00
jheaps f5108de288 Merge pull request 'Handle the messy table: joining, leaving, and arguing about blinds' (#45) from feature/casino-holdem-messy-tables into master
Build and Deploy / build-deploy (push) Canceled after 0s
2026-09-01 09:29:58 -06:00
jheaps ee37cf7bd9 Merge pull request 'Make every tap in the dealer land instantly on a phone' (#44) from feature/casino-holdem-touch-mobile into master
Build and Deploy / build-deploy (push) Canceled after 10s
2026-09-01 09:29:53 -06:00
jheaps 0d1218fa53 Merge pull request 'Hold'em dealer: a test suite, one implementation each, and a pot-announcement fix' (#43) from chore/casino-holdem-tech-debt into master
Build and Deploy / build-deploy (push) Canceled after 0s
2026-09-01 09:29:49 -06:00
jheapsandClaude Opus 5 a72f199139 Light up the seat that's holding the game up
Asked what actually slows a home game down and the answer from people who deal
them is consistent: not how fast the cards come out, but players not noticing the
action is on them. "Waiting for shuffling and players not realizing it's their
action is going to cost much more time than how quickly you spread the flop."

So I measured how obvious the acting seat is, and it was distinguished by a rim
colour and nothing else -- a gold border and a 2px ring where a live seat has a
green border and a 1px one. Same fill, same size, nothing moving. That is a subtle
thing to ask a dealer to spot across a table on a phone, for the one state the
whole pace of the game hangs on.

It reads as lit now rather than merely outlined: a solid gold ring at full
strength, a warm glow separating it from the felt, a warmer fill, and it sits
above its neighbours so the ring is never clipped by the seat next to it.

There's also a slow two-and-a-half second breath, which stops entirely under
prefers-reduced-motion -- the ring carries the whole job on its own for anyone who
would rather nothing moved, and that was checked rather than assumed.

The lift is box-shadow, not transform. A seat's transform is what positions it
around the felt, so setting one here would throw it off its spot -- the same trap
that nearly shipped a 38px seat displacement a few rounds ago.

One measurement I'm not claiming: the acting and live fills differ by only 1.1:1,
because the change is a hue shift rather than a lightness one. The ring and the
glow do the work; the fill just stops it looking like the same chip.

Checked at eight viewports: layout and contrast audits clean, exactly one acting
seat everywhere, and the lit seat is the same size as its neighbours at every
size -- the ring adds no layout. renderAll is 1.8ms with the pulse running, so it
costs nothing. Text on the warmer fill still passes contrast at every size.
137 tests passing.

Sources: pokerchipforum.com/threads/as-a-dealer-how-do-increase-more-hands-per-hour.114914/,
forumserver.twoplustwo.com/27/casino-amp-cardroom-poker/tips-advice-faster-better-dealer-241644/

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 09:20:46 -06:00
jheapsandClaude Opus 5 e0eb0d2079 Say something when the raise isn't a legal raise
Web search turned up the oversized-chip rule described as the single most common
cause of floor disputes, and specifically a home-game problem. Chasing that led to
checking what the bar does with an illegal raise, and the answer was: nothing.

With the minimum at $18 and a player holding $200, typing 13 was accepted without
a word. The engine then treated it as a short raise and correctly declined to
reopen the betting -- right for an all-in, wrong for somebody who just misheard
the bet, and either way the dealer was never told.

The raise entry now says what's wrong and still lets it through. This tool records
what the humans at the table actually did, and a dealer overruling the arithmetic
is right more often than the arithmetic is, so Confirm stays enabled in every case:

  $13 is under the $18 minimum. A legal raise is $18 or more -- otherwise
    it's a call, or all-in.
  All-in for less than a full raise. Legal, but it doesn't reopen the
    betting -- anyone who has already acted owes the difference or folds.
  More than they have. This goes in as all-in for $200.
  $10 is not above the $10 already bet -- that's a call, not a raise.

The second one surfaces the rule fixed two rounds ago at the moment it decides
something, rather than only in the rulings sheet.

Two minimums, which is where the first attempt went wrong

The pad already had a minimum, clamped to what the player can actually put in --
correct for the Min button, wrong for the warnings. For a short stack the clamped
minimum equals their all-in, so neither warning could ever fire for exactly the
player the rule exists for. Kept separate now: one for the button, the rules one
for the warnings.

A regression the audit caught, in the same round that introduced it

The note makes the raise pad taller, --actionBarHeight grows, and the toast --
which floats just above the bar since the occlusion round -- got pushed up over
the table. Measured at 360x640: bar 122px closed, 178px with the pad, 217px with a
note, and the toast climbing from y506 to y411, covering seats outright.

A toast is transient news about the action just taken; the pad is the dealer
deciding the next one. The older one gets out of the way when the pad opens --
nothing is lost, its Undo is also the bar's own undo control. The note is tighter
on short screens, and the pot breakdown hides during entry, since which side pots
exist isn't what's being read while typing an amount. Net: 210px rather than 217px,
and no seat covered that the pad wasn't already covering at 178px.

Two rulings entries, from the same search

The oversized chip -- a single big chip pushed out silently is a call, not a raise,
however big it is; to raise you say so before it lands. And what a full raise is,
which the pad now enforces in words.

137 tests passing. Raise warnings verified across four amounts on a deep stack and
three on a short one, at eight viewports: note shows, pad stays reachable, contrast
clean, Confirm never disabled. Toast still behaves normally when the pad is closed.

Sources: cardplayer.com/.../contracts-and-poker-the-oversized-chip-rule,
pokernews.com/strategy/be-careful-when-betting-essential-wsop-rules-to-remember

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 09:12:17 -06:00
jheapsandClaude Opus 5 d66abda05a Make the dealer puck hittable without making it bigger
Ran the touch-target check that had been sitting in the audit unused. The dealer
puck is 22x22 — half what a fingertip needs — in ten places on the table, and
tapping it hands the button to that seat and re-posts the blinds. Not a cosmetic
control.

It stays 22x22. A puck should look like a puck sitting on the rim of its seat, and
growing it would take area from the seat itself, which a dealer taps far more
often. Instead an invisible pad around it catches the tap: 43x44 hittable on every
phone size, close to four times the area, with the pad pushed down and left away
from the seat the chip juts towards. Verified that no seat tap is stolen — the
occlusion audit is clean at every size.

Three things the check itself got wrong, all found before acting on them

It read element boxes, and boxes lie in both directions. A control padded out with
a transparent ::after responds well past its painted size, and a checkbox wrapped
in a label is hit by tapping anywhere on the label — so it reported the dedicated
dealer toggle as a 24px fault when it sits inside a 320x44 label that already
forwards the tap. It probes outward with elementFromPoint now, which is what a
finger actually experiences.

The probe walked a fixed 40px each way, so everything wider than 80px reported
exactly 80. Every header button came back a suspiciously round "80x42".

And it enforced 44px on desktop, where there is no fingertip. Those same header
buttons are 64px tall on a phone and 42px with a mouse, which is not a defect.
Keyed off hover:none now, the same signal used for the tablet field sizing.

Also removed a ::after pad on the dedicated-dealer checkbox that was both inert --
pseudo-elements don't render on replaced elements -- and unnecessary, since the
label already does the job.

The puck is the one documented exemption: it is deliberately small and "Give the
button" is in the seat menu at full size, so it's a shortcut rather than the only
way through. The reason sits in the code next to the exemption.

Phone sizes: dealer chip 22x22 drawn / 43x44 hittable, zero undersized controls
even with the exemption and the pointer rule forced off. Layout and contrast
audits clean at all seven sizes. 137 tests passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:54:07 -06:00
jheapsandClaude Opus 5 6fa4532781 Fix the contrast the audit could finally measure
Contrast is the one check I've got wrong twice, both times by being clever about
backgrounds. The first version read only background-color, so text on any of the
gradient-painted sheets was measured against transparency and the dealer puck's
dark "D" was reported at 1.2:1 against nothing. The second over-corrected and
treated low-alpha gradient stops as opaque, scoring white-on-dark at 1.0:1.

So this one is built to be incapable of false positives rather than exact.
Backgrounds composite through alpha; where a gradient is involved every colour stop
is tried and the most flattering one used. A finding means the text fails even
under the reading most generous to it.

It's checked against known answers before being believed: black on white at 21:1,
white on white at 1:1, #767676 on white at the 4.54:1 AA boundary, white over a
dark gradient, dark text on that same gradient, white over a 6% white wash on a
near-black page. Nine cases, all correct — and building them found a real bug in
the check, which walked past an opaque gradient and offered the page background as
a candidate. That made it too lenient, which is as bad as the old false positives.

What it found:

Both blind badges failed against their own gradients, in 9px bold text a dealer
reads at a glance across a table. At the worst stop: small blind 3.52:1, big blind
2.15:1. The small blind keeps its blue and darkens to 4.77:1. The big blind flips
to dark text on brighter gold, 5.84:1 — how a gold chip is marked anyway, and it
makes the two badges far easier to tell apart at speed.

Quick Split's footnote names a control the dealer is told to reach for, and that
name inherited the muted grey of the surrounding aside: 3.39:1. Emphasised text
carrying an instruction gets its own colour now.

The felt watermark came back at 1.19:1 and is meant to. It's decoration; holding it
to a reading threshold would mean making it not a watermark. It's aria-hidden now,
which says the same thing where it also does some good, and the audit skips
aria-hidden subtrees.

32 contrast passes over the table, all five overlays and Quick Split at five
viewports: zero findings, zero unmeasured. Layout audit still clean. 137 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:39:07 -06:00
jheapsandClaude Opus 5 2f6d63400a Stop re-typing the layout audit into a console every round
Last round I found a regression I'd shipped two rounds earlier: the standings
table's player-name column was zero pixels wide, at every viewport, and nobody
noticed because the audit that would have caught it only ever existed in a
previous session's scrollback. These checks have been re-written by hand at least
six times, and each time they covered only whatever had just changed.

So they're a file now. uiAudit.js runs in the page — no build step, no
package.json, no dependency — and checks touch-action and tap highlights, that
fields stay selectable and are big enough not to trigger iOS zoom, that nothing
important is covered or stranded off-screen, that no grid column has collapsed to
nothing, and that no text is painted outside its box. runUiAudit.js drives it over
the table, all five overlays and Quick Split at thirteen viewports. Deliberately
outside CI: the unit suite is dependency-free and runs in a second, this one needs
a browser and a running site.

Three real faults it found on its first run

Fields were 13.3px on every iPad. The 16px minimum that stops iOS zooming the page
on focus went in inside a max-width:700px block, and every iPad is wider than
that — so a dealer on a tablet, which is the device this is most likely to be
propped up on, got the table zoomed every time they touched the blind amount. The
reason for that rule was never "narrow screen", it was "iOS". Font size applies at
all widths now, and the 44px touch targets key off hover:none, which is what
actually distinguishes a finger from a mouse.

The seat menu couldn't be scrolled. It had no height limit and no overflow, so on
a phone held sideways its first two actions — Fold and All-in — sat below the
bottom of the screen with no way to reach them. A dealer in landscape could
long-press a seat and then not fold it.

The pot wizard sat under the action bar. Every other overlay is z-index 90-98; the
wizard alone was at 50, below both the action bar at 55 and the toast at 80. In
landscape one of its inputs was completely covered by the Fold button, and a toast
could sit over "Confirm Winner(s)". It was the odd one out rather than a decision.

Three faults in the audit itself, found by checking before acting

Worth recording, because each would have had me "fix" something that wasn't broken:

- an ellipsised name was reported as painting outside its seat. A Range measures
  laid-out text, which runs past the element while overflow:hidden quietly clips
  it. Only overflow that is actually visible can paint over a neighbour.
- every link below the fold was reported as off-screen. Something under the fold on
  a page that scrolls is reachable; the check now asks whether scrolling can bring
  it into view, which is the thing that actually matters.
- a button in the newly-scrollable seat menu came back "covered by null".
  elementFromPoint returns null for a point outside a scrollable ancestor's clip —
  scrolled out of view, not covered by anything.

A toast partly over a control is a warning rather than a failure now: it dismisses
itself and carries a close button, and on a desktop it necessarily lands over a
table that fills the content column. Fully covered by a toast is still a failure,
because that was a real bug once — a seat sat exactly under the Undo button.

Final sweep: 13 viewports, 65 overlay checks, 13 full-page audits, 4 Quick Split
audits, zero failures, one warning. 137 unit tests still passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:31:35 -06:00
jheapsandClaude Opus 5 0d6e7e0b5a Give the standings table back its player names
Went looking for more rules bugs and didn't find any — the blind rotation is fair
over an orbit, nobody is double-charged or skipped when players come and go, and
the big blind's option is handled correctly including heads-up and when the blind
puts them all-in. Those probes are written up below.

What I did find was a regression I shipped two rounds ago.

The name column was zero pixels wide

Adding the "Cash out" column in the bank-audit round made five columns. Four of
them are numbers with a 4.5rem minimum, which with gaps needs 320px — and the
sidebar is about 290px. The name column is the only flexible one, so it absorbed
the shortfall and collapsed to nothing. Player names were not truncated, they
were entirely invisible, and the row quietly overflowed.

Measured at 360, 390, 1024, 1280 and 1600 alike, because the sidebar is narrow at
all of them — this was not a phone problem, it was everywhere except the tablet
layout where the sidebar goes full width. Names were already down to 50px with
four columns, so this had been getting worse before I finished it off.

The table earns its columns now instead of assuming them

Narrow: player, net, cash out — the two numbers a banker settles up from. Wide
enough: the per-hand In and Won as well. A container query rather than a viewport
one, because the sidebar is 290px at both 360px and 1600px viewports and the
thing that matters is the space the table actually has. Names get a real minimum
and ellipsis rather than being crushed. Everything stays in the CSV either way.

    360x640    name 0px -> 152px    Player | Net | Cash out
    390x844    name 0px -> 182px    Player | Net | Cash out
    768x1024   name    -> 389px     Player | In | Won | Net | Cash out
    1280x900   name 0px -> 151px    Player | Net | Cash out

Rules probes that came back clean, so nothing changed:

- blind rotation over 12 hands with 4 players: exactly 3 big blinds each
- a player leaving and rejoining mid-session never causes a double or a skip
- the big blind gets their option when everyone limps, can raise with it,
  and checking it ends the street
- a big blind left all-in by the blind itself gets no phantom option
- heads-up, the button acts first pre-flop and the big blind still has the option
- post-flop the minimum bet falls back to the big blind

Re-checked the guarantees from earlier rounds with the sidebar open: 0 of 50
controls on touch-action auto, 0 tap-highlighted, 0 occluded, stacks still inside
their pucks, no sideways scroll, bank panel still fits and reads correctly.
137 tests still passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 08:07:51 -06:00
jheapsandClaude Opus 5 aeb02d023d A short all-in doesn't reopen the betting
Went looking at dealer procedure again and ended up checking the rule home games
argue about longest. The tool had it wrong.

Blinds 1/2. Seat 4 raises to 10 — a full raise of 8. Seat 1 calls. Seat 2 is
all-in for 13: three more, where a full raise would need eight. Under TDA rule 44
that does not reopen the betting to anyone who has already acted; they owe the
extra three or they fold. Seat 1 and seat 4 were both being handed another go.

Two things were wrong in raiseTo. Any increase at all reset hasActed for every
live player, so a short shove gave the whole table another chance to raise. And
lastRaiseSize was overwritten with the short amount, so the next legal raise was
computed off 3 instead of 8 and came out too small — a second, quieter error that
only shows up when somebody then tries to raise.

Both now key off whether the increase was actually a full raise. A player who had
not yet acted keeps full rights, which falls out naturally because their hasActed
is already false — that distinction is the part of the rule people get wrong in
the other direction.

Everyone still owes the difference: isStreetComplete already asks anyone with
chips left to call, so the short all-in is answered without being re-raiseable.

The rulings sheet now covers what the tool does for you

It had three entries. A dealer challenged on a payout couldn't look up why the
tool did what it did, even for the rules it enforces automatically. Added the
short all-in rule above, uncalled chips coming back rather than being announced
as a side pot, and the odd chip in a chopped pot going to the first player left
of the button — all three are things this tool already decides silently, and all
three are worth being able to point at when somebody objects.

10 tests (137 total): already-acted players aren't reopened but still owe the
difference, a player yet to act keeps full rights, the minimum raise isn't
shrunk, calling it ends the street, no chips are created, and — the other half of
the rule — a full-sized all-in, an ordinary raise, a raise exactly matching the
last one, and a full-sized opening bet all still reopen everyone.

Validated through the real action bar: after the short shove, lastRaiseSize stays
8 and the player who had already called stays settled, while the two who hadn't
acted keep their turn. The longer rulings sheet still fits a phone, scrolls, traps
focus and closes on Escape.

Sources: TDA rules via thelodgepokerclub.com/tda-rules/,
pokernews.com/strategy/deal-with-it-tips-for-mistake-free-dealing-in-home-games-30231.htm

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 07:52:08 -06:00
jheapsandClaude Opus 5 60d192a9ec Let the banker check the cash box against the chips on the table
Ran out of things I could find by auditing the UI, so I went and read what people
who run home games actually complain about. The recurring one isn't a dealing
mistake, it's a banking one: "the last two guys go to cash out at the end of some
all-night game and there isn't enough cash to cover the chips they have."

Every chip on the table is a promise to pay cash for it later. Nothing in here
checked that the promise could be kept, and there's no way to notice by eye —
it's always a rebuy nobody wrote down, or chips taken without paying, and it
stays silent until the box is empty and two people are still holding chips.

The books now add up, or say why not

HoldemState.bankReconciliation compares the cash taken in against every chip that
could be handed back — stacks, chips in front of players, and the pot waiting to
be won. The sidebar shows both numbers and what they mean:

    Balanced — the cash covers every chip on the table.
    $160 more in chips than cash taken. Someone's rebuy probably wasn't
      recorded — the box will be short at cash-out.
    $150 more cash than chips in play. Someone has paid for chips they
      haven't been given.

Money in the middle counts as chips in play, so an open pot isn't reported as a
shortfall — otherwise it would cry wolf during almost every hand.

Players who had bought in were missing from the settle-up

sessionStandings was built from the hand log, so anybody who hadn't yet been in a
recorded hand didn't appear at all. Somebody hands over $200, sits down, folds a
few, and the banker's own record doesn't mention them — then they turn up at
cash-out holding chips the list never accounted for. Anyone with money in the
game is in the list now, whether or not they've played a logged hand.

The settle-up number was already being computed and hidden

cashPosition — chips in front of them less the cash they handed over — was
calculated for every player and written to the CSV, but never shown on screen.
It's the number you read while counting out cash at the end of the night, so it's
a column now.

15 tests (127 total): a buy-in with no hands played still appears and reads as
level rather than a loss, empty chairs stay out, a player who cashed out shows
what they took, both directions of imbalance are caught, an open pot is not a
discrepancy, six played-and-settled hands never knock the books out, a proper
rebuy balances while a chip grab doesn't, and float dust isn't an alarm.

Validated with Playwright through real play — seated, blinds posted, pot open,
hand settled, rebuy — balanced at every step, plus the three seeded verdicts and
a fresh table where the panel stays hidden.

Sources: pokerlistings.com/blog/how-to-run-a-home-poker-game,
chipsoffury.com/learn/how-to-handle-poker-buy-ins-with-friends/

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 07:33:16 -06:00
jheapsandClaude Opus 5 cc6a1902bc Stop the tool from throwing a pot away without saying so
Two ways to lose a hand's money by accident, both found by walking the settle
flow and checking the chip count afterwards.

Next Hand destroyed an unresolved pot silently

Starting the next hand clears every seat's contribution, so money still in the
middle simply vanished. Measured at a four-handed table with $8 in the pot: the
table went from 800 chips to 792, and the toast said "Next hand — button on seat
3. Blinds posted." — not a word about the eight chips that had just left.

It now asks, names the amount, and says what will happen to it. Confirming still
works and the toast says what went. The undo was always there, but a dealer
can't undo something they were never told about.

The wizard's "Close" abandoned a payout that looked finished

After picking winners and confirming, the wizard shows "Final Payouts — Ann $8"
above two buttons: "Start Next Hand" and "Close". Close pays nobody. Under that
heading, with a name and an amount beside it, a dealer taps Close believing the
pot has been pushed — and walks away with the chips still in the middle and the
hand unlogged. The button now says "Cancel — don't pay yet", with a line
underneath spelling out that nothing has been paid.

The warning measures the right thing

The first version counted the pot badge, which was wrong in a way the tests
caught before it shipped: the table-wide base pot is a standing ante for dealers
who don't track stacks, and it re-applies to the next hand rather than being
lost. Quoting it would have put a confirmation in front of those dealers before
every single hand — a nag, on the simplest way to run a table, for money that
wasn't going anywhere.

HoldemState.unresolvedContributions() measures exactly the fields startNextHand
clears: banked contributions, chip counts, and whatever is still in front of
players on this street. Verified both ways in the browser — a base-pot table
showing a $100 pot is not asked at all, a stack-tracking table with $8 live is.

6 tests (115 total): the quoted number equals what actually leaves the table,
mid-street chips are included, a settled hand leaves nothing to warn about, five
settled hands conserve every chip, an empty table has no phantom pot, and a
standing ante is never warned about.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 04:39:51 -06:00
jheapsandClaude Opus 5 a488bbf832 Stop rebuilding the hand log after every fold, call and raise
Profiled a long night at a full table — 24 seats, 200 logged hands — because
that's the state the tool is slowest in and the one a dealer reaches at 2am.

A full re-render cost 30ms, and it runs after every single action. Broken down,
25.7ms of that was renderHandHistory: tearing down and rebuilding 200 rows of a
panel that hadn't changed and that nobody is looking at mid-hand. Everything
else on the table put together came to about 4ms.

The log only changes when a hand is settled or the history is cleared — maybe
once every thirty taps. It now carries a cheap signature (length, hand counter,
and the newest row) and is skipped when that hasn't moved. Entries are immutable
once written and only ever added at the front, so the signature catches a
recorded hand, a cleared history, and an undo that puts a hand back.

    renderAll, every action     30.07ms -> 4.18ms
    renderHandHistory, skipped  25.74ms -> 0ms
    renderHandHistory, rebuild  25.74ms -> 9.05ms

The rebuild got faster too, for the times it does run — once per hand, where
25ms of jank lands exactly on the "next hand" tap. toLocaleTimeString was the
single most expensive call in the loop and a logged hand's timestamp never
changes, so the formatted string is kept; and the rows are built into a fragment
so the panel lays out once instead of two hundred times.

A cache like this is only worth having if it can't go stale — a dealer reading
last hand's payouts while settling this one is worse than a slow panel. 10 tests
cover the invalidation directly: unchanged through a street of betting, changed
by settling, by clearing, by an undo back to a shorter log, by two hands that
differ only in the pot, and by the counter moving on.

Validated with Playwright against the real page: the rendered rows match the
stored history through settling a hand in the wizard, undoing it, clearing the
log and recording again. Touch and layout guarantees from the earlier rounds
still hold — 0 of 87 controls on touch-action auto, 0 tap-highlighted, every
stack still inside its own puck. 109 tests passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 04:24:36 -06:00
jheapsandClaude Opus 5 9ee4816fa4 Make the overlays usable from a keyboard
Measured every overlay in the tool before touching anything. For a dealer running
this on a laptop, all six were broken in the same three ways.

- focus never moved into the dialog: 0 of 5 measured
- Tab walked straight out of the dialog into the page behind the scrim: 5 of 5
- Escape did nothing: only the rulings sheet handled it, 1 of 5
- the pot wizard — the dialog that pays people — had no role and no
  accessible name at all

So opening the pot wizard left focus on the button behind it, tabbing wandered
through a page nobody can see or click, and there was no way back out except the
mouse.

One implementation, not six

The overlays are opened and closed by five different modules, which is why they
each missed the same things. Rather than patch this into all of them, modal.js
watches the `hidden` attribute they already toggle: focus moves to the first real
control on open, Tab and Shift-Tab wrap inside the dialog, Escape closes it, and
focus returns to whatever opened it. `aria-modal` is set while open and removed
on close. An overlay added later gets all of it without anyone remembering to
wire it up, which is the failure being fixed.

Escape presses the dialog's own cancel button rather than just hiding the
overlay, so the confirm dialog still resolves its promise — verified: Escape
resolves it to false rather than leaving an await hanging forever.

Shortcuts.overlayOpen() had the same shape of bug, listing the six overlay ids by
hand. It asks the document now, so a sheet added later can't quietly start
letting "f" fold the table behind it.

Focus you can see

Three focus rules existed for the whole stylesheet, so tabbing relied on the
browser default ring, which on these dark custom-painted controls is close to
invisible. `:focus-visible` keeps it to keyboard use so a dealer tapping Fold
isn't left with a ring on it.

That surfaced something small: `.holdemBtn` used `transition: all`, so the new
outline faded in over 150ms and the ring lagged a step behind the Tab key. The
transition names its properties now. Measured after: 16 of 16 tab stops show a
ring immediately, and the press state from before still animates.

Also

- a mistyped negative stack read back as negative, and flowed into putIn() where
  Math.min(amount, -50) moves a negative amount and adds chips to the table.
  Clamped at zero.
- accessible names for the chip colour, chip value and notes fields, which had none

11 new tests (99 total), covering negative stacks, negative and unreadable
blinds, blinds nobody can cover, and a save that comes back corrupt, truncated,
or from before a field existed.

Playwright: all five overlays re-measured — focus moves in 5/5, Tab and
Shift-Tab trapped 5/5, Escape closes 5/5, focus restored 5/5, role and
aria-modal present 5/5. Shortcuts still fire when no dialog is open and are
still blocked when one is. Touch guarantees from the earlier round unchanged:
0 of 49 controls on touch-action auto, 0 tap-highlighted.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 04:12:55 -06:00
jheapsandClaude Opus 5 db208a1a86 Make big numbers readable, and make the export add up
Seeded a table that looks like a real game — "Mike from work", "Jean-Philippe",
stacks from $12 to $3,200,000 — and measured what the layout does with it.

".00" was costing a third of a seat puck

Every amount rendered with two forced decimal places. Measured, the trailing
".00" is ~22px, against the 64px a seat puck has to show a stack in. So
"$1,250,000.00" needed 102px of a 64px box — and because a number has no spaces
to break on, it neither wrapped nor ellipsised. It drew straight out over the
seat next to it.

Money now shows cents only when there are cents. A chip game deals in whole
numbers nearly all the time, and "$12" says exactly what "$12.00" said in half
the width. A quarter-blind game is untouched: $0.25, $12.50 and $0.75 pots all
still read as before, checked against a 25c/50c table.

That alone took "$1,250,000.00" down to "$1,250,000" — 102px to 80px, still
wider than the 64px puck. The remaining overflow is handled by stepping the font
down on long amounts, which keeps every digit. Abbreviating to "$1.25M" would
have fit more comfortably and was the obvious move, but a dealer reading a
rounded stack off the felt is a worse failure than a small one, and the exact
figure is the entire point of the tool. Measured after: every stack now paints
inside its own puck, nothing spills onto a neighbour.

Because Money.format is the single formatter all 47 call sites go through, this
lands on the seat pucks, action bar, pot breakdown, seat menu, standings and
history at once — the payoff from consolidating it earlier.

The CSV export couldn't be added up

The file exists so a house manager or a disputed chip count has something that
outlives the tab, and every money column in it was written as text:
"$1,250,000.00". The commas were quoted correctly so the file always parsed —
it just imported as strings, so the one thing anyone opens it to do, total a
column, needed every cell cleaned first.

Money columns now go in as bare numbers (1250000.00) with no symbol and no
separators. The columns meant for humans stay human: names are still names,
payouts still read "Alexandra: $1,250,000", and a name with a comma in it is
still quoted.

12 new tests (88 total, all passing), including the case that would be ruined by
dropping cents unconditionally, and a proper quote-aware CSV parser to check
every money cell parses as a number.

Re-ran the occlusion sweep from the previous round at 9 seats, 10 seats and
tournament stacks: still clean, no regression from the font changes.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:54:03 -06:00
jheapsandClaude Opus 5 3d63ba50ee Handle the messy table: people joining, leaving, and arguing about blinds
Home games don't run like a cardroom. People walk up mid-hand, storm off
mid-hand, and pass the button round three times before anyone deals. Probing
each of those found four problems, one of which loses chips.

A player taken out mid-hand took their chips with them

`computeContribution` opened with `if (!player.inHand) return 0`, so the moment
a dealer switched a seat off, everything that seat had already put in vanished
from the pot. Measured: four players in for $2 each, one taken out, pot drops
from $8 to $6. Their $2 was still sitting on the seat record — it just stopped
counting. A dealer following the tool would have paid out $6 of an $8 pot.

Chips already pushed into the middle are dead money. They stay in the pot and go
to whoever wins, whether or not the player is still in the hand, and it is not
theirs to take back. The `inHand` check now guards only the table-wide base-pot
default, which does have to stay tied to being dealt in or every empty chair
would be treated as having anted. The pot wizard counts a departed seat as
folded, so its chips are in the pot but it can never be picked as a winner.

Passing the button before the cards come out

Moving the button left the posted blinds where they were while everything
positional started reading off the new spot, so the money and the blind
positions silently disagreed. While nothing but the blinds are in, moving the
button now refunds them and re-posts from where it lands — pass it round six
times and the chip count is unchanged. Once anybody has acted the blinds are
settled and it stops offering.

Naming the blinds by hand

Blinds were purely positional with no way to overrule them. The seat menu now
offers "Make small blind" / "Make big blind" while only the blinds are in, and a
badge with a ring marks a blind that was named rather than dealt. Naming one
pins the other where it is. It's refused if it would put both blinds on one
player or post to somebody who isn't dealt in, and it lasts one hand — carrying
it forward would freeze the blinds on two people for the night.

The first hand, and a table that empties out

A fresh table puts the button on seat 1 whether or not anyone is sitting there,
and a table can empty out around it. The button is moved onto a real player
before a hand is dealt.

Joining mid-hand

A newcomer was added straight into the live seats and would be asked to act in a
hand they hadn't been paying into. They now sit the hand out and are a full
player from the next one.

Nothing is covered on a phone any more

Swept 13 viewport sizes with elementFromPoint, five points per control, on the
table and every overlay. Before: the action bar covered the bottom seats on
short screens (62px of felt at 360x640), the toast covered up to six seats at
once — Undo button included, so a tap meant for a player hit "Undo" — and in
landscape the table came out 765px tall in a 390px window, putting six of nine
seats off-screen entirely.

- the action bar's real height is published as `--actionBarHeight` and drives
  both the page's bottom padding and the toast, replacing a hardcoded 9rem that
  was wrong the moment the raise pad opened
- landscape sizes the felt from the height that's left, not from width. The
  first attempt did nothing: `padding-bottom` is a percentage of the containing
  block's *width*, so narrowing the wrap left it full height, and a
  `max-width: 900px` rule later in the file was overriding it anyway. It's set
  outright now and lives at the end of the sheet.
- on short screens the toast floats over the action bar's status readout rather
  than over the seats. Nowhere is clear of both; covering something you re-read
  beats covering something you tap.
- headers go compact on short screens, which is where the vertical room came from

13/13 sizes clean, 360x640 through 844x390, portrait and landscape.

Also fixes a marker that flickered: `classList.toggle(cls, undefined)` toggles
rather than clearing, and `manual` is undefined for positional blinds, so the
hand-picked ring appeared on alternate renders.

18 new tests (76 total, all passing).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:40:02 -06:00
jheapsandClaude Opus 5 073ba9a252 Make every tap in the dealer land instantly on a phone
Four measured problems, all of which only show up under a thumb.

Every tap waited on the browser (53 of 53 controls)

Not one interactive element set `touch-action`, so a mobile browser held every
tap for ~300ms to see whether a second one was coming that would mean "zoom in".
That is on the fold button, the call button, every seat, every card slot.

Worse, this tool uses double-tap on a seat as the fold shortcut — which *is* the
browser's zoom gesture. The dealer's fastest shortcut was fighting the page
zooming under their thumb. A single seat tap cost ~300ms of browser hesitation
before the app's own 220ms double-tap window even started.

`touch-action: manipulation` everywhere keeps panning and pinch-zoom and drops
only double-tap-to-zoom, which is precisely the one that was in the way.

Press feedback was the OS grey box (1 `:active` rule in 2385 lines)

Only `.actionBtn` had a pressed state. Everything else — seats, header buttons,
card slots, picker cards, menu rows, toasts, wizard choices — relied on the
translucent grey rectangle the browser paints on tap. So the tap highlight
couldn't just be switched off; the controls needed real pressed states first.
They press down 1px and brighten, in 0.04s, matching the action bar that already
did. Then the grey box goes.

Hover stuck after every tap (21 unguarded rules)

`:hover` has no meaning on touch, but it still applies — and stays applied until
you tap elsewhere. Tapping "Next hand" left it lifted and lightened as though
still under a cursor, and it would have fought the new pressed states directly.
All of them are behind `@media (hover: hover)` now. The one rule that shared a
block with `:focus-visible` was split so keyboard focus survives.

The notes field zoomed the page (iOS)

`#tableNotes` was 13.3px. iOS zooms the viewport when a field under 16px takes
focus, and the dealer is then pinching back out to see the table. Every other
input here was already 16px; this was the straggler.

Seats deliberately have no `:active` rule

Worth writing down because it nearly went in. A seat's `transform` is what
positions it around the felt, so a pressed state setting `transform` drops the
`translate(-50%, -50%)` and throws every pressed seat ~38px off its spot.
Validation caught it: seats already dip via `.seatPressing`, which `pointerdown`
adds in render.js — earlier than `:active` resolves, and it restates the
translate correctly.

Validated with Playwright at 390x844: 53/53 controls off `touch-action: auto` and
0 tap-highlighted across the table, all four overlays and Quick Split; text
fields still selectable and buttons not; 14/14 hover rules gated; presses
measured live via mouse-down (fold reads translateY(1px)/brightness(1.3), a seat
holds its translate with 2px of drift from the shrink alone). Hand played through
the UI including a double-tap fold. No console errors. Unit tests 58/58.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:11:39 -06:00
jheapsandClaude Opus 5 c1a484b97a Pay down the Hold'em dealer's tech debt and give it a test suite
Every module in the dealer ends with a `module.exports` guard, so they were all
written to be testable, and none of them were ever tested. The correctness fixes
that went in recently were checked by hand in a browser console that has since
been closed. This puts a real safety net under them and clears out the
duplication that made those bugs possible in the first place.

Tests (58, ~300ms, no dependencies)

Node's built-in runner, no package.json and no install step: the modules are
plain script-tag files, so `load.js` just requires them and puts them on
globalThis under the names they use to find each other in the page.

The randomised suite is the part that matters. Every bug found in this tool so
far has been shape-dependent — blinds posted to empty chairs, the button
skipping players, heads-up acting out of order — and none of them show up on the
full six-handed table anyone would write a fixed example for. It deals 1000
hands per property across random seat counts, empty chairs, stack depths and
dealer input, checking that chips are never created or destroyed, that the pot
breakdown plus uncalled returns equals the money in, that paying out settles to
the cent, and that the button and blinds always land on real players. The
generator is seeded so a failure reproduces instead of flaking.

One implementation each

- `Math.round(x * 100) / 100` was written out 16 times across 6 files. It's
  `Money.round` / `toCents` / `fromCents` now, in the module both pages already
  load first.
- The chip epsilon was declared twice with the same value under two names
  (`PotCalculator.EPSILON`, `Betting.EPSILON_CHIPS`). Both now point at
  `Money.EPSILON`.
- `Betting.seatAt` and `HoldemState.nextSeatIndex` were the same modular
  arithmetic written twice. Walking the seats is exactly where the button, the
  blinds and the action drifted apart from each other before, so there is one
  of it now.
- The undo snapshot/restore dance appeared three times in main.js, each one
  re-deleting every key on `state` in place. It's `snapshotState` /
  `restoreState` once, with the reason it can't just reassign written down.

A bug the tests found

`layers()` and `uncalledReturns()` both decide what money was never contested,
separately, and disagreed. `uncalledReturns` measured each player against the
biggest live contribution *including their own*, which makes the biggest bet its
own yardstick — so the one case it exists for came back empty, and `layers()`
announced the overbet as a side pot only the bettor could win.

With Ann shoving 200 into Ben's 60, the dealer was told "main pot 4, side pot 3,
side pot 116, side pot 140" — four pots, one of them fictional. It now reads
"main pot 123, uncalled 140 back to Ann", which is what a dealer says out loud.
Layers are derived from contributions with the uncalled money already removed,
so the two can't disagree again. A band every payer folded out of is now merged
into the pot below rather than dropped, which was quietly shrinking the
announced pot in another edge case.

Validated with Playwright against a real hand on both surfaces — the table's
live pot readout and Quick Split's all-in mode — plus undo, with no console
errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 03:00:36 -06:00
jheaps 6117886e70 Merge pull request 'Hold'em dealer: five correctness fixes and four polish passes (replaces #33–#41)' (#42) from feature/casino-holdem-dealer-batch into master
Build and Deploy / build-deploy (push) Successful in 1m12s
Reviewed-on: #42
2026-09-01 02:47:00 -06:00
jheaps ae8744672f Merge remote-tracking branch 'origin/feature/casino-quicksplit-side-pots' into feature/casino-holdem-dealer-batch 2026-09-01 02:38:01 -06:00
jheaps 328ef56af5 Merge remote-tracking branch 'origin/feature/casino-holdem-contrast' into feature/casino-holdem-dealer-batch 2026-09-01 02:38:01 -06:00
jheaps 7714e16547 Merge remote-tracking branch 'origin/feature/casino-holdem-touch-audit' into feature/casino-holdem-dealer-batch 2026-09-01 02:38:01 -06:00
jheaps cc31cd033b Merge remote-tracking branch 'origin/feature/casino-holdem-wizard-polish' into feature/casino-holdem-dealer-batch
# Conflicts:
#	JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
2026-09-01 02:38:01 -06:00
jheaps f324793732 Merge remote-tracking branch 'origin/feature/casino-holdem-seat-count-safety' into feature/casino-holdem-dealer-batch 2026-09-01 02:37:41 -06:00
jheaps 56d9d740b5 Merge remote-tracking branch 'origin/feature/casino-holdem-odd-chip' into feature/casino-holdem-dealer-batch 2026-09-01 02:37:41 -06:00
jheaps e5b02b00e4 Merge remote-tracking branch 'origin/feature/casino-holdem-button-moves' into feature/casino-holdem-dealer-batch 2026-09-01 02:37:41 -06:00
jheaps 34cd7dbc59 Merge remote-tracking branch 'origin/feature/casino-holdem-allin-runout' into feature/casino-holdem-dealer-batch
# Conflicts:
#	JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
2026-09-01 02:37:41 -06:00
jheapsandClaude Sonnet 5 4f72dda6fa Ask before a smaller table deletes the players sitting in it
Typing a smaller number into "Seats at table" silently threw away every seat past
the new count. Nine players down to six removed three of them — names, stacks and
buy-ins gone, no confirmation, no way back.

It also quietly falsified the settle-up. Those players keep their rows in the
session standings, because the hand log remembers who won what, but the seat they
were read from no longer exists — so a player who had just won a pot showed as
having bought in for nothing and holding nothing.

Shrinking now says who it is about to remove and asks, and either way the change
is undoable like the rest of the table's actions. Growing the table, and
shrinking onto chairs nobody is sitting in, still happen without a word.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 02:33:03 -06:00
jheapsandClaude Sonnet 5 cc1b5011a1 Give the odd chip to the player the rule says gets it
Quick Split tells the dealer a leftover chip goes "to the first player left of
the button". The pot wizard, on the same tool, handed it to whoever the dealer
happened to tick first. Two screens, same split, different answer.

The wizard now orders the winners around the table from the button before the
calculator shares the pot out, so the leftover lands where the instruction says
it does. The calculator itself is unchanged — it already gave leftovers to the
front of the list, it was just being handed the list in the order the checkboxes
were clicked.

It's pennies, but it's pennies in front of the person who didn't get them, and a
dealer who has just read the other screen's instruction out loud looks wrong.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 02:05:57 -06:00
jheapsandClaude Sonnet 5 18bb23ad6d Pass the button to the next player, not the next chair
The button moved one seat per hand regardless of whether anyone was sitting
there. At a ten-seat table with four players — an ordinary home game — it spent
five hands out of eight on an empty chair.

That reads as untidy, but the real problem is fairness. Position and the blinds
are meant to come round to each player at the same rate. Rotating past empty
chairs breaks that: a player with three empty seats to their right takes the
button far less often than one sitting in a cluster, while both pay blinds every
time the rotation reaches them.

The button now skips to the next seat that's actually dealt in, so with four
players it visits those four in turn and each takes it once every four hands.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:50:16 -06:00
jheapsandClaude Sonnet 5 ae591e4212 Stop asking all-in players to act
A player who is all-in is still in the hand and still wins pots, but has nothing
left to decide. The engine didn't know that: it picked the next player to act by
walking to whoever was still in the hand, chips or no chips.

So a dealer with three players all-in pre-flop was asked to check for each of
them on the flop, the turn and the river — nine taps to say nothing — and the
action could land mid-street on someone who'd already pushed their last chip in.

Now the action passes over players who are all-in, and once fewer than two
people have chips left the betting is simply over: the bar runs Deal Flop, Turn,
River, Showdown straight through, and says "All-in — running out the board" so
the absence of a question reads as deliberate rather than as a stuck screen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:37:19 -06:00
jheapsandClaude Sonnet 5 89d79a0f7d Post the blinds to the players rather than to the chairs
Blinds were assigned by seat offset — always button+1 and button+2 — which is
only right when everyone is dealt in and there are three or more of them.

Heads-up it had the blinds backwards. The button posted the big blind and acted
second before the flop; the rule is the opposite on both counts. That's the end
of every home tournament, dealt wrong.

Worse, the offsets don't care whether anyone is sitting in those chairs. With
two players at a nine-handed table, or with the seats left of the button sitting
out, the blinds landed on empty seats and simply weren't posted — no money in
the middle, and SB/BB badges pointing at nobody.

Blinds now walk the players who are actually dealt in, starting from the button,
with heads-up as its own case: the button is the small blind, acts first
pre-flop, and the big blind acts first afterwards. The badges come from the same
function as the money, so what the table shows and what gets posted can't
disagree.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:22:15 -06:00
jheapsandClaude Sonnet 5 d0f75d0c14 Lift the faint greys to something readable in a dim room
This is a dark interface used in poker rooms and kitchens with the lights down,
and I'd been generous with grey-on-black. Measured every piece of text against
what's actually painted behind it: thirteen declarations came in under 4.5:1.

The worst was the empty card slot's "+" at 2.63:1 — an affordance telling the
dealer the slot is tappable, and the least legible thing on the screen. Then a
run of #777 labels around 3.9 to 4.1: the standings header, the time on a logged
hand, the currency mark in the add-chips field. The rulings source line sat at
3.62 and the rulings link itself at 4.39.

All of them move up just far enough to clear 4.5 rather than being flattened to
white, so the hierarchy between a heading, a value and a hint survives.

Worth recording what the measurement can't see: it reads background-color, so
text on a gradient — the pot badge, the dealer button, the blind badges — comes
back as a false result in both directions, and those were checked by eye
instead. The felt watermark is deliberately at 6% and stays that way.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 01:06:20 -06:00
jheapsandClaude Sonnet 5 6b3a7dc09d Bring every phone target up to size, not just the ones I happened to check
Last round I measured one screen and found the smallest targets in the tool were
on it. So I measured all of them: sixty-four controls at phone width, flagging
anything under 44px. Twenty-three came back.

The sidebar had never had the touch pass the seat form got — its inputs, selects
and small buttons sat at 26 to 30px, and the buttons that delete a chip colour
or a custom field were 28px wide, which is a bad size for something destructive.
The rulings link was a 14px-tall underlined word.

The seat form itself had drifted: its mobile sizing rule named five fields, and
the stack field added two rounds later wasn't one of them, so it sat at 28px
beside its 44px neighbours. Both that rule and the new sidebar one are now
written against the form and the panel rather than a list of field names, so the
next field added inherits the size instead of waiting to be noticed.

The dealer button keeps its 22px disc — it's a chip sitting on a seat, and
drawing it thumb-sized would swallow the puck — but takes taps through an
invisible 44px pad. Verified the seat underneath still gets its own taps.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 00:38:29 -06:00
jheapsandClaude Sonnet 5 38488f2978 Give the pot wizard the same tap targets as everything else
Measured the screen where the money actually gets decided and found the smallest
targets in the tool: 37px winner rows, 13px checkboxes, a 36px confirm. Every
other surface has been held to 44px and up for several rounds — the one place a
mis-tap pays the wrong player was the one place that hadn't been.

Winner rows are now 56px and up, with a 22px box and a green fill when chosen,
because a dealer confirms this with players watching and a small checkbox is
easy to misread across a table. The row already was the tap target; now it looks
like one.

Each row also splits into name, what they can win, and their cards as separate
pieces rather than one run-on sentence, so the hand can be read at a glance. The
confirm takes the full width, and on a phone the summary's buttons stack with
the one that ends the hand furthest from a stray thumb.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 00:22:14 -06:00
jheapsandClaude Sonnet 5 7c64749267 Let Quick Split handle the pot it used to send people away for
Quick Split answered the easy half of the question and printed a footnote for
the hard half: if somebody was all-in for less, go set up the full table. That's
the moment a dealer least wants to be setting up a table.

A second mode takes what each player put in and lists the main pot and each side
pot with who can win it, plus anything uncalled that goes back. Rows can be
marked folded, so dead money lands in the right pot without its owner being able
to win it. The running total is shown so the breakdown can be checked against
the chips actually on the table.

It reuses the same pot maths as the full table rather than growing a second
implementation, which means the two screens can't drift apart and Quick Split
inherits the uncalled-bet and everyone-folded handling that was fixed there.

Also fixes hiding: these sections carry a `display` declaration, which outranks
the browser's own [hidden] rule, so toggling `hidden` on them did nothing and
both modes' controls showed at once.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 00:07:37 -06:00
jheaps e8d6098d39 Merge pull request 'Add chips to a seat without leaving the table' (#32) from feature/casino-holdem-rebuys into master
Build and Deploy / build-deploy (push) Successful in 1m7s
Reviewed-on: #32
2026-08-31 23:57:01 -06:00
jheaps 5d1a6b7399 Merge branch 'master' into feature/casino-holdem-rebuys 2026-08-31 23:56:53 -06:00
jheaps a816b32426 Merge pull request 'A rulings reference for the calls that come up mid-hand' (#30) from feature/casino-holdem-rulings into master
Build and Deploy / build-deploy (push) Canceled after 31s
Reviewed-on: #30
2026-08-31 23:56:40 -06:00
jheaps 5271d5fbad Merge branch 'master' into feature/casino-holdem-rulings 2026-08-31 23:56:32 -06:00
jheapsandClaude Sonnet 5 552ec1a542 Add chips to a seat without leaving the table
Somebody rebuying is the most common interruption in a cash game, and the only
way to record it was to open the seat editor and retype a stack from memory.

Press and hold a seat and there's now "Add chips…": quick amounts off the
table's starting stack — half a buy-in, a buy-in, two — with a keypad for the
times it isn't one of those. It's undoable like every other seat action, and it
records cash handed over as well as chips added, because those answer different
questions. The stack moves every hand; what a player has paid in only moves
when they buy.

The export gains bought in, chips now, and up/down, so settling up at the end of
the night is reading a row rather than reconstructing it.

Getting that last column right needed care. Chips in front of a player disagree
with the hand log for as long as a hand is open — and since blinds post the
instant the previous hand settles, that window is almost always. Counting what a
player already has in the current pot as still theirs makes the cash position
agree with the hand log at any moment, including across a rebuy.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:53:15 -06:00
jheaps 5c807d1581 Merge pull request 'Feature/casino holdem sidebar polish' (#31) from feature/casino-holdem-sidebar-polish into master
Build and Deploy / build-deploy (push) Successful in 1m5s
Reviewed-on: #31
2026-08-31 23:36:45 -06:00
jheaps a5b9c26902 Merge branch 'master' into feature/casino-holdem-sidebar-polish 2026-08-31 23:36:37 -06:00
jheapsandClaude Sonnet 5 9a5b974fda Add a rulings reference for the calls that come up mid-hand
The tool handles the money; the other half of dealing is the calls. A home-game
dealer who isn't a pro has to rule on a misdeal or a prematurely turned board
card with a table watching, and the usual outcome is an argument settled by
whoever is loudest.

A "Common rulings" link under the table opens a reference for exactly the
situations that come up: what counts as a misdeal, what to do when the fourth
board card gets turned before the betting is finished, and how acting out of
turn is normally treated. It follows the Tournament Directors Association
rules, and says plainly that house rules win and that a dispute gets settled
between hands rather than during one.

It's a sheet rather than a sixth sidebar panel — a dealer consults this rarely
and needs it immediately, which is the opposite of what a panel is for.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:35:59 -06:00
jheaps b563a56563 Merge pull request 'Put bets on the felt as chips instead of numbers in a box' (#29) from feature/casino-holdem-bet-chips into feature/casino-holdem-sidebar-polish
Reviewed-on: #29
2026-08-31 23:35:28 -06:00
jheapsandClaude Sonnet 5 72ee2fb2b7 Put bets on the felt as chips instead of numbers in a box
The seat puck showed a green figure that meant "what this player just bet"
during a street and "what they've put in this hand" between streets — two
different numbers wearing the same badge, sitting directly above their stack
size. Easy to misread, and nothing on the table looked like money.

Bets now sit on the felt between their player and the pot, as a chip coloured
by the largest denomination in the chip legend that fits the amount: a $30 bet
shows green against a $25 chip, a $2 bet shows white. When a street closes the
chips come off the felt and the pot badge keeps the money, the way they'd be
swept in. The puck's figure now always means the same thing — this player's
total for the hand.

On a phone the bet clings to its own puck instead of sitting on its own ring.
That isn't a preference: sweeping every radius between the seat ring and the
pot badge at 390px showed each one collides with either the pucks or the pot,
because the felt has no room for a third ring at that size.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:24:39 -06:00
jheapsandClaude Sonnet 5 ae927983e7 Tidy the sidebar down from five panels to four
Each round added a panel, and the sidebar became five identical dark accordions
with no hierarchy between them — which is the polish debt I've been pointing at
for a while without paying it down.

Session Standings and Hand History were always two views of the same log, so
they're one Session panel now, with subheads and the export and clear buttons
at the bottom where they act on both. Base pot amount is the pre-live-betting
way of working, so it moves into a "Without live betting" disclosure inside
Table Setup rather than sitting between two settings a dealer actually touches.
Panels are ordered by how often they're opened, and the hints are shorter.

Also stops "Small blind" wrapping onto two lines beside its input, which made
the pair look like a mistake rather than a pair.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 23:06:30 -06:00
jheapsandClaude Sonnet 5 4ceeed71a2 Add keyboard control for a dealer working on a laptop
The phone got gestures a few rounds back; the desktop still had a mouse and
nothing else. On a bar that gets tapped thirty times a hand, moving a hand to
the trackpad between every decision is the slow part.

F folds, C checks or calls, R opens the raise pad, A shoves, Enter confirms a
raise or moves the hand on, Esc backs out, U takes the last action back. Each
button wears its key in the corner, hidden on touch devices where it would just
be clutter on a button being tapped anyway.

The shortcuts press the same buttons a dealer would tap rather than calling the
betting engine themselves, so there's no second code path to drift out of step
with what the bar is actually offering — if a button isn't on screen, its key
does nothing.

Nothing fires while a name, amount or note is being typed, or while an overlay
owns the keyboard. Typing "Francesca" into a seat name must not fold the table.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:52:52 -06:00
jheapsandClaude Sonnet 5 95c523738c Let the dealer take back an action from the bar
An audit of what's accumulated turned up an inconsistency worth fixing: double-
tapping a seat to fold someone was undoable, but tapping Fold in the action bar
— the button a dealer actually uses all hand — was not. Nor was Call, Raise, or
dealing a street. The safest path through the tool was the shortcut, and the
main one had no way back.

Every action taken from the bar now goes through one place that records it
first, and the bar carries a standing Undo naming what it will take back:
"Ben's fold", "dealing the flop". Twelve steps deep, because a misclick usually
gets noticed a beat late and one level of undo would only cover the luckiest
case.

It's a control rather than a toast per action. A dealer taps this bar
constantly, and a toast after every fold is noise they'd learn to ignore —
which is precisely when they'd miss the one that mattered.

The history is discarded whenever a hand starts or settles, because those
snapshots describe a hand that no longer exists. The whole-hand Undo on the
toast already covers that boundary.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:37:03 -06:00
jheapsandClaude Sonnet 5 1fcc058dd8 Show how long the current player has been thinking, and how many are left
Dealing guides put the biggest time sink not in shuffling or spreading cards
but in players who haven't noticed the action is on them — and they list the
per-street count of players remaining as part of what a dealer announces.

The action bar now carries both. A stopwatch runs on whoever is to act, going
amber at thirty seconds and red at a minute, so a dealer can see at a glance
whether someone is genuinely deciding or has stopped paying attention. Nothing
happens when it grows; it is a prompt for the dealer, not a clock on the
player. Beside it is the count of players still in the hand.

The timer restarts only when the action actually moves, so re-rendering for an
unrelated reason doesn't put a thinking player back to zero, and it ticks by
repainting its own text rather than rebuilding the buttons under the dealer's
thumb. It stops when the hand ends or the bar hides.

Also stops the side-pot breakdown from crying wolf: it now waits for someone to
actually be all-in. Unequal amounts in front of players are normal mid-street —
the blinds alone are unequal — and layering those announced a side pot on every
hand before anyone had acted.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:21:39 -06:00
jheapsandClaude Sonnet 5 ee25e2a4fa Show the side-pot breakdown before the showdown, not after
Dealing guides are consistent that the most common side-pot error isn't
miscounting the chips, it's failing to announce the breakdown clearly before
the showdown. The tool had the arithmetic right but only revealed it after the
hand, one layer at a time, as the wizard asked who won each.

The breakdown is now visible while the hand is still running: as soon as an
all-in splits the money, the action bar lists the main pot and each side pot
with the amount and who can win it, and the wizard repeats it under "Announce
before the showdown" before asking for a winner. A hand with a single
undivided pot shows nothing, so the line means something when it appears.

Chips nobody could call are called what they are. Money above the largest live
contribution was never contested — usually an overbet by someone who then
folded — so it's listed as "Uncalled, back to X" rather than padding a side
pot nobody can win. The wizard's leftover warning says the same thing instead
of telling the dealer to un-fold someone to win it, which was the wrong advice
for chips that should simply be pushed back.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 22:10:19 -06:00
jheapsandClaude Sonnet 5 dabf1100c9 Add double-tap and press-and-hold shortcuts to the seats
Folding someone meant opening their editor, finding the Fold box, ticking it,
and dismissing the sheet — four interactions for the thing that happens most
often in a hand.

Double-tap a seat to fold, and again to put them back. Press and hold for a
quick menu of everything else you do to one player: fold, all-in, sit out or
deal in, hand them the button, or open the full editor. Both are undoable the
same way the header buttons are, because a gesture is easier to fire by
accident than a button.

The single tap now waits out the double-tap window before opening the editor —
a fifth of a second of latency on opening a form, in exchange for a fold that
doesn't also fling a sheet up over the table. A press that moves is a drag, so
the hold is cancelled and swapping seats still works, and the puck shrinks
slightly while held so a press that's being counted looks different from one
that's being ignored.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 21:54:56 -06:00
jheapsandClaude Sonnet 5 0759d89d13 Pick hole cards from a grid instead of typing them
Typing "AH" into a two-character box is fine at a desk and miserable at a table:
it needs a keyboard, it accepts nonsense like "ZZ", and it happily deals the same
card to two players.

The two card fields become face-up card slots. Tapping one opens a 52-card grid
— four suit columns, thirteen rank rows, one tap per card — and picking the first
card moves straight on to the second, because a dealer picking hole cards is
always picking two. Cards already dealt elsewhere at the table are greyed out and
say who has them, so a duplicate is visibly out of play rather than silently
accepted.

Cards are still stored as the same two plain characters everything has always
read; the suit only becomes a symbol on its way to a human. That formatting is
now shared, so the table pucks, the seat editor, the pot wizard, the hand history
and the CSV all say A♥ K♦ rather than three of them saying AH KD.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 21:32:43 -06:00
jheaps 01a1728971 Merge pull request 'Live betting: tap the action instead of typing totals' (#21) from feature/casino-holdem-live-betting into master
Build and Deploy / build-deploy (push) Successful in 1m11s
Reviewed-on: #21
2026-08-31 19:19:16 -06:00
jheapsandClaude Sonnet 5 7bedee2934 Track the betting live so the dealer taps instead of types
Until now a dealer kept the money in their head and typed each player's total
into a form. This adds the running hand: blinds post themselves, every seat has
a stack, and one tap per decision moves the action on.

The bar sits at the bottom of the screen where a thumb already is, and shows
only what's true right now — Fold, then Check or Call with the amount owed, then
Bet or Raise. Calling more than a player has left says "Call all-in" rather than
quoting a number they can't cover. Raising offers min, half pot, pot and all-in
before it offers a keypad, because a dealer calling "raise to sixty" wants a
button. When everyone has answered, the bar becomes Deal Flop, then Turn, River,
and finally Showdown, which hands off to the pot wizard that already existed.
Winnings go straight back in front of the winner, so the next hand's stacks are
already right.

The money hooks into what was already there rather than running beside it: a
street's chips sweep into each seat's amountOverride, the same field the pot
badge, the pot wizard, the session standings and the CSV export all read. So
nothing downstream changed, and a dealer who prefers the old typed workflow
still has it.

Tables saved before this exist without any of these fields, so load() fills
them in rather than making someone reset a table mid-session.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 19:01:08 -06:00
jheaps 7143e4e701 Merge pull request 'Feature/casino holdem undo toasts' (#19) from feature/casino-holdem-undo-toasts into master
Build and Deploy / build-deploy (push) Successful in 1m15s
Reviewed-on: #19
2026-08-31 18:41:22 -06:00
jheaps e8797b8dc2 Merge branch 'master' into feature/casino-holdem-undo-toasts 2026-08-31 18:41:13 -06:00
jheaps 48bd3682f2 Merge pull request 'Make the four seat states readable across the table' (#18) from feature/casino-holdem-seat-legibility into feature/casino-holdem-undo-toasts
Reviewed-on: #18
2026-08-31 18:39:58 -06:00
jheapsandClaude Sonnet 5 1e1bdcccce Make the four seat states readable across the table
A seat could be live in the hand, folded, sitting out, or empty, and three of
those looked nearly the same: a plain dark puck, or a dimmed one. An unclaimed
chair was indistinguishable from a nameless player, and nothing positively said
"this player is in" — the dealer had to infer it from whether a dollar badge
happened to be showing.

Each state now has one look. Live gets a green edge. Folded gets a red edge and
a struck-through name, so it reads as out rather than merely faint. An empty
chair is the only dashed outline and says so. Sitting out just fades — and
fades less than it used to, since that player is still someone the dealer may
need to deal back in, and at 0.4 the name was nearly gone.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 18:36:54 -06:00
jheaps aee172a5cd Merge pull request 'Quick Split: chop a pot from your phone without setting up a table' (#15) from feature/casino-holdem-quick-split into master
Build and Deploy / build-deploy (push) Successful in 1m14s
Reviewed-on: #15
2026-08-31 18:33:05 -06:00
jheaps d206758729 Merge branch 'master' into feature/casino-holdem-quick-split 2026-08-31 18:32:56 -06:00
jheaps 4afc3649cc Merge pull request 'Make the dealer table usable on a phone' (#16) from feature/casino-holdem-mobile-table into feature/casino-holdem-quick-split
Reviewed-on: #16
2026-08-31 18:31:52 -06:00
jheaps 3866bdb335 Merge pull request 'Replace the browser confirm boxes with undo and in-page dialogs' (#17) from feature/casino-holdem-undo-toasts into feature/casino-holdem-mobile-table
Reviewed-on: #17
2026-08-31 18:31:40 -06:00
jheaps 71b33f8623 Merge pull request 'Give the Hold'em dealer a session record: hand history, CSV export and standings' (#14) from feature/casino-holdem-hand-history into master
Build and Deploy / build-deploy (push) Successful in 1m13s
Reviewed-on: #14
2026-08-31 18:31:19 -06:00
jheapsandClaude Sonnet 5 d01c67fc45 Replace the browser confirm boxes with undo and in-page dialogs
Three system confirm() dialogs sat in the middle of dealing. The worst was on
Next Hand — the most-pressed button in a session, asking "are you sure?" every
single time, stealing focus and, on a phone, taking over the screen.

Next Hand now just happens, and a toast says where the button landed with an
Undo beside it for the few seconds anyone would notice a misclick. The same
undo covers the pot wizard, which is the more valuable case: paying the wrong
player is a real mistake, and rolling it back restores the hand history entry
and the session standings along with the seats. The snapshot is in-memory only
— an undo is for the mistake you just made, not one from last night.

The two genuinely destructive actions keep a confirmation, but as an in-page
dialog that matches the table instead of the browser's own: Escape or the
backdrop cancels, Enter confirms, and on a phone the buttons go full width with
Cancel nearest the thumb.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 18:22:50 -06:00
jheapsandClaude Sonnet 5 a39675f975 Make the dealer table usable on a phone
Two things were broken on a narrow screen. The seat ring is drawn on a radius
measured from the table wrap, so at phone width the left- and right-most seats
hung off the edge of the viewport — at a ten-handed table two players were
simply not on screen. And tapping a seat opened an editor far enough down the
page that it looked like nothing had happened.

The ring is now inset by half a puck on phones, which keeps every seat on
screen, and the pucks are narrower there so neighbours stop overlapping where
the ring flattens out at the top and bottom. The seat editor becomes a bottom
sheet that slides up over the table: grip, Done button, tap the same seat again
to dismiss, table still visible above it. Its controls grow to thumb size, and
every field goes to 16px — below that iOS zooms the page on focus and the
dealer has to pinch back out mid-hand.

Header actions become full-width tap targets, and the page padding tightens to
give the table the width it needs. Nothing above 700px changes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 18:11:09 -06:00
jheapsandClaude Sonnet 5 870dee4a29 Add a phone-first Quick Split screen for chopping a pot mid-hand
The dealer table is a two-handed, big-screen tool. A dealer standing over a
chopped pot with a phone in one hand and players waiting needs an answer in
seconds, not a table to set up first — so /casino/texasholdem/quicksplit is
the whole job on one screen: type the pot, tap how many ways, tap the
smallest chip in play, read the payout. Nothing to configure, nothing saved.

It splits in whole chips rather than raw arithmetic, because a dealer can't
push half a chip across the felt: everyone gets the same stack and the
remainder is called out as odd chips going one at a time to the first
players left of the button. A pot that doesn't divide into the chosen
denomination says so instead of quietly hiding the difference.

Money formatting moves out of render.js into its own file so this screen can
use it without pulling in the whole seat/chip UI.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:53:18 -06:00
jheapsandClaude Sonnet 5 bcfc1f2f52 Track session standings so the dealer can settle up at the end of the night
The hand log answered "what happened in hand 12"; it couldn't answer "what
does Alice owe". Each resolved pot now records a per-seat ledger (what that
player put in, what they took down) alongside the payouts, and a Session
Standings panel sums it into one row per player — paid in, won, net, sorted
by who's up. The CSV export carries the same table, so one file covers both
the hand-by-hand trail and the cash-out numbers.

Keyed by seat id rather than name, so renaming or reseating a player doesn't
split their totals. Hands recorded before this change have no ledger and are
skipped rather than counted wrong.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:37:48 -06:00
jheapsandClaude Sonnet 5 d168fedf1f Add a CSV export for the Texas Hold'em hand history log
A dedicated dealer needs a paper trail that outlives the browser tab, not
just a scrollable in-app panel — the Export CSV button downloads the full
resolved-pot log (hand #, time, pot, payouts) for recordkeeping or a
disputed chip count after the session ends.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-08-31 17:26:11 -06:00
jheapsandClaude Sonnet 5 68e263f325 Add a persistent hand history log to the Texas Hold'em dealer tool
Give the dealer a running record of resolved pots — hand number, total
pot, who got paid what, and the time — instead of losing that
information the moment the pot wizard closes. It's the kind of audit
trail a real dealer needs when a chip count gets disputed after the
fact.

- state.js tracks state.handNumber and state.handHistory (capped at 200
  entries) and persists both through the existing localStorage save/load
  path, with backfill defaults for tables saved before this change.
- potWizard.js now hands its final payouts back to the caller instead of
  discarding them once "Start Next Hand" is clicked.
- main.js records a history entry (skipping hands where nobody actually
  won anything) before advancing to the next hand, and wires up a "Clear
  history" control that also resets the hand counter.
- A new sidebar panel lists history entries newest-first; the header
  shows a running "Hand #N" badge next to the existing subtitle.

Verified via Playwright MCP: seated two players, resolved a pot, and
confirmed the header's hand counter incremented, the history panel
listed the payout, the entry survived a full page reload (localStorage
persistence), and Clear History reset both the log and the counter. No
console errors during the flow.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3NiiUDimoKDJ8GjC3KL3x
2026-08-31 17:15:23 -06:00
jheaps 105a74bc51 Merge pull request 'Feature/casino texas holdem' (#13) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m11s
Reviewed-on: #13
2026-08-31 16:06:56 -06:00
jheaps dce7e0f688 Merge pull request 'Add dedicated-dealer mode and redesign the Hold'em table' (#12) from feature/casino-holdem-dealer-mode-polish into feature/casino-texas-holdem
Reviewed-on: #12
2026-08-31 15:58:17 -06:00
jheapsandClaude Sonnet 5 3851786b90 Add dedicated-dealer mode and redesign the table's visuals
Add a "Dedicated dealer (not a seat)" toggle for tables run by someone
outside the game: it hides every seat's dealer button while blinds keep
rotating automatically off the same internal position each hand.

Also gave the table a real casino look, based on reference felt/rail and
seat-pod patterns found via web search (huggingface.co/spaces/Estazz/
PokerGameDesigner, github.com/Tehes/poker): a richer radial felt gradient,
a gold piping ring inside a wood-toned rail, a subtle center watermark, a
pill-shaped chip-well pot badge, softer seat pucks with a pill-badge total
and red/black suit coloring on cards, and casino-chip-styled dealer/blind
badges (the dealer chip now dims to a faint disc on other seats and only
lights up on hover, decluttering the ring of ten clickable buttons down to
one visible button on the actual dealer).

Verified via Playwright MCP: toggle hides/reveals dealer chips correctly,
SB/BB still track it, no console errors.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 15:26:32 -06:00
jheaps 8e7d94e6f9 Merge pull request 'Fix Texas Hold'em dealer page: script timing crash and seat overlap' (#11) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m8s
Reviewed-on: #11
2026-08-31 15:03:37 -06:00
jheapsandClaude Sonnet 5 25bf67062a Fix Texas Hold'em dealer page: script timing crash and seat overlap
main.js ran an IIFE immediately, but the layout renders @section Scripts
inside <head> before <body> exists, so every getElementById() call hit
null. Wrapped init in DOMContentLoaded, matching the rest of the codebase
(e.g. ChessScripts/chessMain.js).

Separately, the original seat cards packed a full data-entry form (name,
toggles, 2 cards, 5 chip counters, $ override, custom fields) into each of
up to 24 positions around a circle — at any screen size, adjacent seats
overlapped badly. Replaced with compact seat "pucks" for the table view
(name, cards, total, blind/dealer badges) plus a dedicated seat editor
panel in the sidebar that opens for whichever seat is clicked.

Verified via Playwright MCP: load with no console errors, drag-and-drop
seat swap, and a 3-way side-pot resolution (short all-in capped correctly,
remainder awarded to the next-best hand) end to end.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 15:01:18 -06:00
jheaps 5d71572df6 Merge pull request 'Add Casino demo section with a Texas Hold'em dealer's pot-splitting assistant' (#10) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m16s
Reviewed-on: #10
2026-08-31 14:39:02 -06:00
jheapsandClaude Sonnet 5 98f95df8fb Add Casino demo section with a Texas Hold'em dealer's pot-splitting assistant
New /casino hub page (Texas Hold'em live, other table games marked coming
soon) and /casino/texasholdem: a drag-and-drop seating table with
auto-rotating blinds, per-seat chip/dollar contribution tracking, and a
side-pot resolution wizard that asks who won and who's next until every
dollar in the pot (including dead money from folds) is assigned.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 14:25:44 -06:00
jheaps b7c9227bb8 Merge pull request 'Learn fractional win-rate advantage instead of unbounded plus/minus' (#9) from feature/fractional-learned-weights into master
Build and Deploy / build-deploy (push) Successful in 1m25s
2026-08-31 13:43:09 -06:00
jheapsandClaude Sonnet 5 ddb309f6db Learn fractional win-rate advantage instead of unbounded plus/minus
Piece-square and feature weights now converge to a presence-weighted
fractional win-rate per cell (percent of games won with a piece there
minus percent lost), tracked via running visits/score sums instead of
an incrementally clamped nudge. The training signal is also scaled by
how much of a game's result is NOT already explained by the winner's
average material edge (via the standard centipawn win-probability
curve), so a piece no longer gets credit merely for existing on the
winning side of a material blowout in the endgame.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 13:40:57 -06:00
jheaps 2db4048ed2 Merge pull request 'Time out Stockfish process exit so finished self-play games cant wedge in the store' (#8) from fix/stockfish-dispose-hang into master
Build and Deploy / build-deploy (push) Successful in 1m18s
Reviewed-on: #8
2026-08-31 12:22:28 -06:00
jheapsandClaude Sonnet 5 2f53c2f0ed Time out Stockfish process exit so finished self-play games can't wedge in the store
DisposeAsync awaited WaitForExitAsync with no timeout; a Stockfish process that
doesn't exit on "quit" blocked GameStore's removal task forever, leaving the
finished game stuck in the store and cluttering the live-watch list indefinitely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 11:23:59 -06:00
jheaps 7562ded92a Merge pull request 'Bound how long the blog cache serves stale entries' (#7) from fix/blog-cache-staleness into master
Build and Deploy / build-deploy (push) Successful in 1m4s
Reviewed-on: #7
2026-08-26 17:24:29 -06:00
Josh-HeapsandClaude Opus 5 392b9ec212 Bound how long the blog cache serves stale entries
A failed fetch fell back to the cached value with no limit, so an unreachable
blog API kept the post list rendering while every uncached slug returned 404.
Stale entries now survive an hour past their TTL, then get dropped and logged
at error so the outage surfaces the same way for every key.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 17:22:51 -06:00
jheaps 769c7819e0 Merge pull request 'fix workflows take 2' (#6) from fix/workflows into master
Build and Deploy / build-deploy (push) Successful in 1m6s
Reviewed-on: #6
2026-08-26 15:46:12 -06:00
Josh-Heaps c048529654 fix workflows take 2 2026-08-26 15:45:29 -06:00
jheaps e8eb90a085 Merge pull request 'Put workflow in the right spot' (#4) from fix/workflows into master
Build and Deploy / build-deploy (push) Failing after 1m7s
Reviewed-on: #4
2026-08-26 15:39:47 -06:00
Josh-Heaps fc167ae2b0 Put workflow in the right spot 2026-08-26 15:39:11 -06:00
jheaps 088f05e0d2 Merge pull request 'add deploy workflow for gitea' (#3) from gitea/workflows into master
Build and Deploy / build (push) Failing after 34s
Build and Deploy / deploy (push) Skipped
Playwright Tests / playwright-tests (push) Failing after 50s
Reviewed-on: #3
2026-08-26 15:33:17 -06:00
Josh-Heaps 04dcae7993 add deploy workflow for gitea
.NET / build (pull_request) Successful in 50s
Playwright Tests / playwright-tests (pull_request) Failing after 54s
2026-08-26 15:15:51 -06:00
Josh Heaps f956f4bffa Merge pull request #29 from JoshHeaps/chess-ui-overhaul
Build and Deploy / build (push) Failing after 1m22s
Build and Deploy / deploy (push) Skipped
Playwright Tests / playwright-tests (push) Failing after 58s
Bug fixes
2026-06-13 22:03:35 -06:00
Josh-Heaps a53b0c2e43 Bug fixes 2026-06-13 22:01:12 -06:00
Josh Heaps edc4d70678 Merge pull request #28 from JoshHeaps/chess-ui-overhaul
change deploy
2026-06-13 21:34:22 -06:00
Josh-Heaps 3bfcbaa190 change deploy 2026-06-13 21:33:53 -06:00
Josh Heaps 59b318b75c Merge pull request #27 from JoshHeaps/chess-ui-overhaul
revert everything, these changes were a mistake
2026-06-13 20:30:57 -06:00
Josh-Heaps e5e0c9cc48 revert everything, these changes were a mistake 2026-06-13 20:30:26 -06:00
Josh Heaps 3947bd0daf Merge pull request #26 from JoshHeaps/chess-ui-overhaul
bug fixes
2026-06-13 20:14:20 -06:00
Josh-Heaps b99525ae51 fix deploy 2026-06-13 20:13:54 -06:00
Josh-Heaps 9da7b1986b bug fixes 2026-06-13 20:06:56 -06:00
Josh Heaps a0b8c2602a Merge pull request #25 from JoshHeaps/chess-ui-overhaul
fix deploy
2026-06-13 19:44:26 -06:00
Josh-Heaps 7604798e68 fix deploy 2026-06-13 19:43:40 -06:00
Josh Heaps 02163a4fc4 Merge pull request #24 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-13 18:47:56 -06:00
Josh-Heaps 1680d13b05 bug fix 2026-06-13 18:47:31 -06:00
Josh-Heaps a1e2ad9c2d Bug fixes 2026-06-13 18:44:14 -06:00
Josh Heaps 26b4c0e59b Merge pull request #23 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-13 17:32:16 -06:00
Josh-Heaps aeef320cb4 auto train 2026-06-13 17:30:09 -06:00
Josh-Heaps d71e485611 New learning strategy 2026-06-13 17:11:07 -06:00
Josh Heaps d09a067377 Merge pull request #22 from JoshHeaps/chess-ui-overhaul
Change data location
2026-06-12 19:53:34 -06:00
Josh-Heaps acbee436bb Change data location 2026-06-12 19:53:07 -06:00
Josh Heaps d452ee414b Merge pull request #21 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-12 18:02:44 -06:00
Josh-Heaps f09ca60315 foo bar 2026-06-12 18:02:09 -06:00
Josh-Heaps 743c395e11 Choose color against cpu 2026-06-12 10:51:26 -06:00
Josh Heaps 62e146648c Merge pull request #20 from JoshHeaps/chess-ui-overhaul
Chess UI overhaul
2026-06-10 17:01:22 -06:00
Josh-HeapsandClaude Opus 4.8 6402ef268c Rework the chess page into a chess.com-style layout
Board with player bars and captured trays on the left, a game panel with
move list, status, and action buttons on the right. The page is locked to
the viewport (no scrolling); on phones the panel collapses into a slide-up
menu reachable from a slim status bar. Cache-busts the chess scripts too.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:35 -06:00
Josh-HeapsandClaude Opus 4.8 c92c46e6d4 Render the board from pushed state
Clients now render from the state delivered with each move/update and
drop stale or echoed updates via a ply-version guard, instead of issuing
a full game-state fetch per event. Adds captured-piece trays, a material
advantage badge, a move list, a status line, and the mobile menu toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:22 -06:00
Josh-HeapsandClaude Opus 4.8 e8a3bfe432 Push authoritative game state on every move
The move endpoint now returns the full resulting board state, and the
controller and CPU orchestrator broadcast it (plus captured pieces and a
ply version) over SignalR, so clients render from one payload instead of
re-fetching. Removes the client-driven hub relay.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-10 16:58:11 -06:00
Josh Heaps 8b78fd589c Merge pull request #19 from JoshHeaps/feature/customChessEngine
Add killer-move ordering and a search benchmark
2026-06-09 19:03:21 -06:00
Josh-HeapsandClaude Opus 4.8 be07f7504e Add killer-move ordering and a search benchmark
Killer moves supply the quiet-move ordering the search lacked: ~1.45x faster at depth 8 (up to 1.88x in quiet positions) with identical play. Adds bench.ps1 and test/bench_main.cpp to time the search, with a BENCH_DISABLE_KILLERS compile toggle so the script can measure before/after.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 19:01:57 -06:00
Josh Heaps 7bfbae7301 Merge pull request #18 from JoshHeaps/feature/customChessEngine
Feature/custom chess engine
2026-06-08 19:15:41 -06:00
Josh-HeapsandClaude Opus 4.8 b651683e63 Add PGN export and a Copy PGN button for finished games
- Record moves in standard algebraic notation (GameState.SanHistory), built at
  move time in ChessService (captures, disambiguation, castling, promotion,
  en passant, and the check/mate suffix)
- Add PgnExporter.ToPgn (seven-tag header + movetext + result) and a
  GET /api/chess/{gameId}/pgn endpoint
- Show a Copy PGN button when a game ends on both the play and watch pages,
  prefetching the PGN so it works during the post-game cleanup window

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 19:14:39 -06:00
Josh-HeapsandClaude Opus 4.8 d87cea572f Feed move history and halfmove clock to the engine for repetition detection
- Track HalfmoveClock in GameState/ChessService (reset on captures and pawn moves)
- Emit the real halfmove clock in ToFen and add GameState.RepetitionHistory()
  (prior positions since the last irreversible move) in ChessEngineHelpers
- Pass that history through IChessEngine.GetBestMoveAsync to the native engine;
  Stockfish ignores it

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 18:59:10 -06:00
Josh-HeapsandClaude Opus 4.8 5345427e0c Add transposition table, iterative deepening, and repetition-aware search
- Refactor the search to negamax (single side-relative score + alpha/beta window)
- Add a shared, process-wide transposition table: lock-free XOR-verified slots,
  persists across games, with depth-gated and difficulty-capped score reuse so a
  weak bot can't borrow a stronger game's deeper analysis
- Drive the search with iterative deepening, seeding each depth's move ordering
  from the previous one
- Seed prior-position history (Position::seed_history) over a new engine_best_move
  history parameter so the engine detects threefold/50-move draws the FEN can't carry

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 18:58:59 -06:00
Josh-Heaps 76b054ee9d More efficient searching 2026-06-08 17:28:04 -06:00
Josh-HeapsandClaude Opus 4.8 2db95cec34 Add spectator watch page, forfeit-on-new-game, CPU self-play, and threefold repetition
- /watch live feed of active games with click-to-fullscreen and per-game back button
- Starting a new game forfeits the in-progress game; opponent is flagged the winner
- Watch CPU vs CPU self-play games; results shown for ~30s before cleanup
- Detect threefold repetition in the C# rules layer and end the game as a draw

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 16:21:49 -06:00
Josh Heaps 9b69489c4e Merge pull request #17 from JoshHeaps/feature/customChessEngine
Feature/custom chess engine
2026-06-08 14:17:52 -06:00
Josh-HeapsandClaude Opus 4.8 fc2a2e122b Build libchess_engine.so in CI deploy and pin runner to ubuntu-24.04
Compile the native engine and copy it into Resources/ before publish so it
ships to prod automatically. Runner pinned to ubuntu-24.04 to match the
server's glibc.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-08 14:12:50 -06:00
Josh-Heaps c3280aa6d3 add custom chess engine 2026-06-08 13:58:22 -06:00
Josh Heaps d2ae34f530 Merge pull request #16 from JoshHeaps/feature/blog
Update blog css
2026-03-07 12:27:39 -07:00
Josh-Heaps 52d468a46e Update blog css 2026-03-07 12:26:58 -07:00
Josh Heaps 7fcfb270a1 Merge pull request #15 from JoshHeaps/feature/blog
fix css
2026-03-06 17:16:19 -07:00
Josh-Heaps ea60ee5a4b fix css 2026-03-06 17:15:51 -07:00
Josh Heaps 3dc1bc452e Merge pull request #14 from JoshHeaps/feature/blog
Add cache invalidation
2026-03-06 17:08:41 -07:00
Josh-Heaps 99a9474ef7 Add cache invalidation 2026-03-06 17:05:48 -07:00
Josh Heaps 4884c32fc8 Merge pull request #13 from JoshHeaps/feature/blog
Replace file-based blog with API-backed service
2026-03-06 15:44:44 -07:00
Josh-HeapsandClaude Opus 4.6 8043eee4cc Replace file-based blog with API-backed service
BlogService now fetches from Media.JoshHeaps.Net API via HttpClient
with in-memory caching (5-min TTL) and stale cache fallback.
Removed Markdig/YamlDotNet dependencies. All page models now async.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-06 15:33:51 -07:00
Josh Heaps 5fd3a5d7c1 Merge pull request #12 from JoshHeaps/AddProject
Add cloud storage to projects section of front page
2025-10-22 11:58:33 -06:00
Josh-Heaps 9969c6e3cf Add cloud storage to projects section of front page 2025-10-22 11:54:48 -06:00
Josh Heaps 0788015cb0 Merge pull request #11 from JoshHeaps/feature/generalUpdate
Feature/general update
2025-10-17 13:06:02 -06:00
jheaps 34de3c1abc Update href for chess github 2025-10-17 12:57:17 -06:00
jheaps 311d62a719 Update images 2025-10-17 12:48:10 -06:00
jheaps 4217bf3703 Fix headless issue 2025-10-17 12:29:49 -06:00
jheaps fd68ada82e Get fancy wit it 2025-10-17 12:14:37 -06:00
jheaps 7c77cd0e52 Add square numbers and bug fixes 2025-10-17 12:10:08 -06:00
jheaps 93bdb3518a Bug fixes 2025-10-17 11:41:50 -06:00
Josh Heaps 69e1e8450d Merge pull request #10 from JoshHeaps/tech-debt/chess-cleanup
Add demo button for media.joshheaps.net
2025-10-13 10:18:17 -06:00
jheaps 09b408a75b Add demo button for media.joshheaps.net 2025-10-13 10:15:44 -06:00
Josh Heaps e74de40d2e Merge pull request #9 from JoshHeaps/tech-debt/chess-cleanup
Tech debt/chess cleanup
2025-10-03 11:27:23 -06:00
jheaps 03dd308449 game removal code cleanup 2025-10-03 11:24:02 -06:00
jheaps 54e4f25be7 Styling 2025-10-03 10:45:20 -06:00
jheaps c2622132bc Revert site back to normal, add memory lane 2025-10-03 10:43:15 -06:00
jheaps 9bd7eb283e Remove bad test 2025-10-03 10:42:57 -06:00
Josh Heaps 9b86bfafe0 Merge pull request #8 from JoshHeaps/tech-debt/chess-cleanup
Tech debt/chess cleanup
2025-10-02 10:19:41 -06:00
jheaps 95b4ae63ea Anniversary page <3 2025-10-02 10:16:05 -06:00
jheaps edab13f8e4 Javascript cleanup 2025-09-25 10:46:24 -06:00
Josh Heaps ea78302041 Merge pull request #7 from JoshHeaps/tests/CreatePlaywrightTests
Add playwright tests with github actions
2025-09-24 14:43:01 -06:00
Josh Heaps 3b91055666 Merge pull request #6 from JoshHeaps/UpdateParticleSimulatorButton
Update particle simulator button
2025-09-19 16:31:17 -06:00
jheaps b4acdee6e3 Fix auto ip updates 2025-09-19 16:29:44 -06:00
jheaps 10bf9b3d30 Bring debug.js back (I don't remember killing it) 2025-09-19 16:09:47 -06:00
Josh Heaps a3b384b221 Merge pull request #5 from JoshHeaps/UpdateParticleSimulatorButton
remove button to show off github actions
2025-09-02 11:20:47 -06:00
jheaps be0a753945 remove button to show off github actions 2025-09-02 11:18:44 -06:00
Josh Heaps eb3d15527a Merge pull request #4 from JoshHeaps/UpdateParticleSimulatorButton
aewpoifhjp
2025-09-02 11:15:27 -06:00
jheaps bb0d611b21 aewpoifhjp 2025-09-02 11:14:49 -06:00
Josh Heaps 636f133288 Merge pull request #3 from JoshHeaps/UpdateParticleSimulatorButton
Update particle simulator button
2025-08-29 16:31:21 -06:00
jheaps 847f5cc970 Update button sizing 2025-08-29 16:30:18 -06:00
jheaps 75d4ee8f9a Update button text 2025-08-29 16:28:35 -06:00
jheaps d0272adb51 stop running build twice after push 2025-08-29 16:27:37 -06:00
170 changed files with 22119 additions and 664 deletions
+6 -2
View File
@@ -1,9 +1,13 @@
{
"permissions": {
"allow": [
"Bash(dotnet test:*)"
"Bash(dotnet test:*)",
"Bash(dotnet build)",
"Bash(dir:*)",
"Bash(dotnet add:*)",
"Bash(dotnet build:*)"
],
"deny": [],
"ask": []
}
}
}
+97
View File
@@ -0,0 +1,97 @@
# .gitea/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [ "master" ]
workflow_dispatch: {}
concurrency:
group: deploy-${{ gitea.ref }}
cancel-in-progress: true
jobs:
build-deploy:
# Gitea's runner images are lean, so the toolchain is installed below rather
# than assumed. The glibc that libchess_engine.so links against comes from
# this image, not from the runner host — keep it matched to the prod server.
runs-on: ubuntu-latest
steps:
- name: Install toolchain
run: |
apt-get update
apt-get install -y --no-install-recommends \
cmake build-essential rsync openssh-client
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
- name: Build native chess engine (libchess_engine.so)
run: |
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/chess_engine/build
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
# Publishing the project rather than the solution keeps chess_engine.vcxproj
# (Windows-only MSBuild C++ targets) and the UiTests assemblies out of it.
- name: Publish
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
- name: Verify payload
run: |
set -euo pipefail
test -f publish/Resources/libchess_engine.so \
|| { echo "libchess_engine.so missing from publish output"; exit 1; }
test -x publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt \
|| { echo "stockfish is not executable"; exit 1; }
- name: Prepare SSH
env:
SSH_KEY: ${{ secrets.SSH_KEY }}
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT }}
run: |
set -euo pipefail
PORT="${SSH_PORT:-22}"
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# --exclude chess-data: never let --delete remove the learned-engine training
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
- name: Rsync to server
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT }}
SSH_USER: ${{ secrets.SSH_USER }}
TARGET_DIR: ${{ secrets.TARGET_DIR }}
run: |
set -euo pipefail
PORT="${SSH_PORT:-22}"
rsync -az --delete --exclude 'chess-data' \
-e "ssh -p $PORT -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
publish/ "$SSH_USER@$SSH_HOST:$TARGET_DIR/"
- name: Reload and restart service
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT }}
SSH_USER: ${{ secrets.SSH_USER }}
SERVICE_NAME: ${{ secrets.SERVICE_NAME }}
run: |
set -euo pipefail
PORT="${SSH_PORT:-22}"
ssh -p "$PORT" -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
"sudo systemctl daemon-reload \
&& sudo systemctl restart '$SERVICE_NAME' \
&& systemctl --no-pager status '$SERVICE_NAME' --lines=0"
+21 -4
View File
@@ -14,7 +14,9 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
# Pinned (not ubuntu-latest) to match the prod server's Ubuntu/glibc so the
# compiled libchess_engine.so loads there. Keep this == the server's release.
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -24,9 +26,16 @@ jobs:
with:
dotnet-version: '8.0.x' # adjust if needed
- name: Restore
run: dotnet restore
run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj
- name: Build native chess engine (libchess_engine.so)
run: |
cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release
cmake --build native/chess_engine/build
cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/
- name: Publish
run: dotnet publish -c Release -o ./publish
run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish
- name: Upload artifact
uses: actions/upload-artifact@v4
@@ -45,6 +54,12 @@ jobs:
name: site-publish
path: publish
# GitHub artifacts don't preserve the Unix executable bit, so Stockfish (the only file
# the app spawns as a subprocess) arrives non-executable. Restore 755 here; rsync -a then
# carries it to the server, where the service user can run it regardless of file owner.
- name: Restore Stockfish executable bit
run: chmod 755 publish/Resources/stockfish-ubuntu-x86-64-sse41-popcnt
- name: Prepare SSH
run: |
install -m 700 -d ~/.ssh
@@ -54,7 +69,9 @@ jobs:
- name: Rsync to server
run: |
rsync -az --delete -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
# --exclude chess-data: never let --delete remove the learned-engine training
# data, which lives in the deploy dir unless ChessEngine__WeightsPath is set.
rsync -az --delete --exclude 'chess-data' -e "ssh -p ${{ secrets.SSH_PORT || 22 }} -i ~/.ssh/id_rsa" \
publish/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.TARGET_DIR }}/
- name: Reload and restart service
+2 -4
View File
@@ -4,8 +4,6 @@
name: .NET
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
@@ -21,6 +19,6 @@ jobs:
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
run: dotnet restore JoshHeaps.Net.slnf
- name: Build
run: dotnet build --no-restore
run: dotnet build JoshHeaps.Net.slnf --no-restore
+26
View File
@@ -0,0 +1,26 @@
# Unit tests for the Texas Hold'em dealer's money logic.
#
# The betting engine, the side-pot calculator and the table state are plain script-tag
# modules with no build step, so they run directly under Node's built-in test runner —
# no package.json, no install step, nothing to keep up to date.
name: Hold'em tests
on:
pull_request:
branches: [ "master" ]
push:
branches: [ "master" ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Run Hold'em unit tests
run: node --test tests/holdem/*.test.js
+2 -2
View File
@@ -19,10 +19,10 @@ jobs:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
run: dotnet restore JoshHeaps.Net.slnf
- name: Build solution
run: dotnet build --no-restore
run: dotnet build JoshHeaps.Net.slnf --no-restore
- name: Install Playwright browsers
run: |
+7 -1
View File
@@ -3,6 +3,9 @@
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# Learned chess engine weights (runtime training output, not source)
chess-data/
# User-specific files
*.rsuser
*.suo
@@ -360,4 +363,7 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
FodyWeavers.xsd
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
native/**/build/
+13
View File
@@ -11,6 +11,19 @@ public class ApiTests : PageTest
private IAPIRequestContext? _apiContext;
private TestConfiguration Config => TestConfiguration.Instance;
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
if (!Config.Playwright.Headless)
{
Environment.SetEnvironmentVariable("HEADED", "1");
}
if (Config.Playwright.SlowMotion > 0)
{
Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString());
}
}
[SetUp]
public async Task Setup()
{
-14
View File
@@ -81,18 +81,4 @@ public class MultiplayerTests : PageTest
// Should see SignalR connected message in console
Assert.That(consoleLogs, Does.Contain("✅ SignalR connected").Or.Contain("SignalR connected"));
}
[Test]
public async Task Move_Updates_Are_Sent_Via_SignalR()
{
// This would require more complex setup with two players
// For now, just verify the SignalR methods exist in the JavaScript
var jsContent = await Page.EvaluateAsync<string>(@"
() => {
return document.querySelector('script[src*=""chessLogic.js""]') ? 'chessLogic.js loaded' : 'not found';
}
");
Assert.That(jsContent, Is.EqualTo("chessLogic.js loaded"));
}
}
+36 -2
View File
@@ -1,26 +1,60 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34728.123
# Visual Studio Version 18
VisualStudioVersion = 18.6.11822.322 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "chess_engine", "native\chess_engine\chess_engine\chess_engine.vcxproj", "{2579BBBC-1830-4342-BC10-0A4182DC84C7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.ActiveCfg = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.Build.0 = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.ActiveCfg = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.Build.0 = Debug|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.ActiveCfg = Release|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.Build.0 = Release|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.ActiveCfg = Release|Any CPU
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.Build.0 = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.ActiveCfg = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.Build.0 = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.ActiveCfg = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.Build.0 = Debug|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.ActiveCfg = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.Build.0 = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.ActiveCfg = Release|Any CPU
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.Build.0 = Release|Any CPU
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.ActiveCfg = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.Build.0 = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.ActiveCfg = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.Build.0 = Debug|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.ActiveCfg = Debug|Win32
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.Build.0 = Debug|Win32
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.ActiveCfg = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.Build.0 = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.ActiveCfg = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.Build.0 = Release|x64
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.ActiveCfg = Release|Win32
{2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.Build.0 = Release|Win32
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+9
View File
@@ -0,0 +1,9 @@
{
"solution": {
"path": "JoshHeaps.Net.sln",
"projects": [
"JoshHeaps.Net\\JoshHeaps.Net.csproj",
"JoshHeaps.Net.UiTests\\JoshHeaps.Net.UiTests.csproj"
]
}
}
@@ -0,0 +1,24 @@
---
title: My First Post
date: 2026-03-06
summary: Welcome to my blog! Here I'll share thoughts on software development, projects I'm working on, and things I find interesting.
tags: [dev, personal]
---
# Welcome to My Blog
I've been meaning to start writing about the things I build and learn, and I'm finally getting around to it.
## What to Expect
I plan to write about:
- **Projects** I'm working on, like this website and the other things on my portfolio
- **Software development** tips and patterns I find useful
- **Problem solving** approaches that have helped me grow as a developer
## Why a Blog?
Building things is great, but explaining *how* and *why* you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.
Stay tuned for more posts!
@@ -0,0 +1,20 @@
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace JoshHeaps.Net.Controllers;
[ApiController]
[Route("api/[controller]")]
public class BlogController(IBlogService blogService, IConfiguration configuration) : ControllerBase
{
[HttpPost("invalidate")]
public IActionResult InvalidateCache([FromHeader(Name = "X-Invalidate-Key")] string? key)
{
var expectedKey = configuration["BlogApi:InvalidateKey"];
if (string.IsNullOrEmpty(expectedKey) || key != expectedKey)
return Unauthorized();
blogService.ClearCache();
return Ok();
}
}
+199 -97
View File
@@ -1,60 +1,73 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
namespace JoshHeaps.Net.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ChessController(IChessService chessService, IHubContext<ChessHub> chessHub, IBackgroundTaskQueue queue) : ControllerBase
public class ChessController(
IChessService chessService,
IBackgroundTaskQueue queue,
IChessEngineFactory engineFactory,
IComputerMoveOrchestrator orchestrator,
ILearnedWeightsStore weightsStore,
IGameStore gameStore,
ISelfPlayCoordinator selfPlay,
AutoTrainingSettings autoTraining,
IHubContext<ChessHub> chessHub) : ControllerBase
{
/// <summary>
/// Store of ongoing games.
/// </summary>
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
private static ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
/// <summary>
/// Create a new chess game and store it in-memory.
/// </summary>
[HttpGet("new")]
[HttpGet("new/{difficulty}")]
public ActionResult CreateGame(int difficulty = 20)
public ActionResult CreateGame(int difficulty = 20, string color = "random")
{
var gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState;
gameStore.Add(gameState);
gameState.IsVsComputer = true;
gameState.WhiteJoined = true;
gameState.BlackJoined = true;
Guid playerId = Guid.NewGuid();
Guid computerId = Guid.NewGuid();
var isWhite = Random.Shared.Next(2) == 0;
var isWhite = color.ToLowerInvariant() switch
{
"white" => true,
"black" => false,
_ => Random.Shared.Next(2) == 0,
};
gameState.Computer = new(difficulty);
var computer = engineFactory.Create(difficulty);
if (isWhite)
{
gameState.WhitePlayerId = playerId;
gameState.BlackPlayerId = computerId;
gameState.BlackComputer = computer;
}
else
{
gameState.WhitePlayerId = computerId;
gameState.BlackPlayerId = playerId;
gameState.WhiteComputer = computer;
queue.Queue(async () =>
{
// Give user's browser time to connect to signalR and such.
await Task.Delay(TimeSpan.FromSeconds(1));
await gameState.Computer.MakeMove(gameState, chessHub, chessService);
await orchestrator.PlayAsync(gameState);
});
}
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
return Ok(new
{
@@ -64,6 +77,55 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
});
}
/// <summary>
/// Create a computer-vs-computer game and auto-play it move by move, broadcasting each
/// move so it can be watched on the spectator page. Each side's engine and skill can be
/// chosen independently; when the learned engine plays, the game also trains it.
/// </summary>
[HttpGet("watch/cpu")]
[HttpGet("watch/cpu/{difficulty}")]
public ActionResult CreateSelfPlayGame(
int difficulty = 4,
string whiteEngine = "custom",
string blackEngine = "custom",
int? whiteSkill = null,
int? blackSkill = null)
{
var config = new SelfPlayConfig(
ParseEngineKind(whiteEngine), whiteSkill ?? difficulty,
ParseEngineKind(blackEngine), blackSkill ?? difficulty);
var (gameId, _) = selfPlay.StartGame(config);
return Ok(new { GameId = gameId });
}
private static ChessEngineKind ParseEngineKind(string value) => value.ToLowerInvariant() switch
{
"stockfish" => ChessEngineKind.Stockfish,
"customlearned" or "learned" => ChessEngineKind.CustomLearned,
_ => ChessEngineKind.Custom
};
/// <summary>
/// Current number of background auto-training games (and the allowed maximum). Auto-training
/// itself runs only outside Development; this reflects the target the service is keeping.
/// </summary>
[HttpGet("autotrain")]
public ActionResult GetAutoTrain() =>
Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
/// <summary>
/// Set how many auto-training games run concurrently (clamped to 0..max; 0 pauses training).
/// Takes effect live — the background service tops up or drains toward the new count.
/// </summary>
[HttpPost("autotrain")]
public ActionResult SetAutoTrain([FromQuery] int count)
{
autoTraining.GameCount = count; // clamped inside the setter
return Ok(new { count = autoTraining.GameCount, max = AutoTrainingSettings.MaxGames });
}
/// <summary>
/// Joins the "pool" of chess players.
/// Test code expects to receive a GUID for the player
@@ -73,12 +135,12 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
public ActionResult JoinGame()
{
Console.WriteLine("joining game");
GameState? gameState = _games.Values.FirstOrDefault(g => g.IsOpen);
GameState? gameState = gameStore.All.FirstOrDefault(g => g.IsOpen);
if (gameState == null)
{
gameState = chessService.CreateNewGame();
_games[gameState.GameId] = gameState;
gameStore.Add(gameState);
}
Guid playerId = Guid.NewGuid();
@@ -96,7 +158,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
isWhite = false;
}
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
return Ok(new
{
@@ -106,51 +168,72 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
});
}
/// <summary>
/// List in-progress games for spectators: both sides present and the game not yet decided.
/// </summary>
[HttpGet("active")]
public ActionResult GetActiveGames()
{
var activeGames = gameStore.All
// In-progress games, plus finished computer-vs-computer games still in their result window.
.Where(g => g.WhiteJoined && g.BlackJoined
&& ((!g.IsCheckmate && !g.IsStalemate && !g.IsForfeited && !g.IsThreefoldRepetition) || g.IsComputerVsComputer))
.Select(g => new
{
g.GameId,
g.IsVsComputer,
g.IsComputerVsComputer,
WhiteEngine = g.WhiteEngineKind.ToString(),
BlackEngine = g.BlackEngineKind.ToString(),
CurrentPlayer = g.CurrentPlayer.ToString(),
MoveCount = g.MoveHistory.Count,
g.IsCheck
})
.OrderByDescending(g => g.MoveCount);
return Ok(activeGames);
}
/// <summary>
/// The learned engine's piece-square bonus table, for the weights-visualization page:
/// one 64-entry array per piece type (Pawn..King), white-relative (A1=0 .. H8=63).
/// </summary>
[HttpGet("weights")]
public ActionResult GetLearnedWeights()
{
var names = new[] { "Pawn", "Knight", "Bishop", "Rook", "Queen", "King" };
var featureNames = new[] { "Mobility N", "Mobility B", "Mobility R", "Mobility Q", "Passed", "Isolated", "Doubled", "King safety" };
var snapshot = weightsStore.Snapshot();
return Ok(new
{
mg = snapshot.Mg.Select((squares, i) => new { name = names[i], squares }),
eg = snapshot.Eg.Select((squares, i) => new { name = names[i], squares }),
features = snapshot.Features.Select((value, i) => new { name = featureNames[i], value })
});
}
/// <summary>
/// Get the state of an existing game by ID.
/// </summary>
[HttpGet("{gameId}")]
public ActionResult GetGameState(Guid gameId)
{
if (!_games.TryGetValue(gameId, out var gameState))
if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found");
var response = new
{
gameState.GameId,
CurrentPlayer = gameState.CurrentPlayer.ToString(),
gameState.IsCheck,
gameState.IsCheckmate,
gameState.IsStalemate,
EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null,
gameState.WhiteCanCastleKingside,
gameState.WhiteCanCastleQueenside,
gameState.BlackCanCastleKingside,
gameState.BlackCanCastleQueenside,
Pieces = gameState.Pieces
.Where(p => p.Position.Row >= 0)
.Select(p => new {
p.Id,
p.Type,
p.Color,
p.Position.Row,
p.Position.Col,
p.HasMoved
}),
gameState.MoveHistory
};
return Ok(response);
return Ok(gameState.ToDto());
}
/// <summary>
/// Make a move in the specified game.
/// Make a move in the specified game.
/// The test passes a JSON body with a MoveDto.
/// </summary>
[HttpPost("move")]
public ActionResult MakeMove([FromBody] MoveDto moveDto)
public async Task<ActionResult> MakeMove([FromBody] MoveDto moveDto)
{
if (!_games.TryGetValue(moveDto.GameId, out var gameState))
if (!gameStore.TryGet(moveDto.GameId, out var gameState))
return NotFound("Game not found");
// Check if player is authorized to move
@@ -158,7 +241,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
var expectedPlayerId = isWhiteMove ? gameState.WhitePlayerId : gameState.BlackPlayerId;
if (moveDto.PlayerId != expectedPlayerId)
return Forbid("You are not the current player.");
return StatusCode(403, "You are not the current player.");
// Make sure player owns the piece
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId);
@@ -167,31 +250,80 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
return NotFound("Chess piece Id does not exist");
if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black))
return Forbid("You cannot move this piece.");
return StatusCode(403, "You cannot move this piece.");
var result = chessService.MakeMove(gameState, moveDto);
if (!result.Success)
return BadRequest(result);
if (result.IsCheckmate || result.IsStalemate)
{
// queue game removal
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(1));
}
var isGameOver = result.IsCheckmate || result.IsStalemate || result.IsThreefoldRepetition;
if (isGameOver)
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
else if (gameState.IsVsComputer)
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
else
{
// increase timeout if play continues.
if (gameState.IsVsComputer)
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
else
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
}
gameStore.ScheduleRemove(gameState.GameId, _multiplayerGameTimeout);
if (gameState.IsVsComputer && gameState.Computer is not null)
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
var state = gameState.ToDto();
return Ok(result);
// Broadcast the move (with the full resulting state) to everyone watching this
// game. The mover also receives this echo but drops it via the version guard,
// since it already rendered the same state from this response.
await chessHub.Clients.Group(gameState.GameId.ToString())
.SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state);
var sideToMoveEngine = gameState.CurrentPlayer == PieceColor.White
? gameState.WhiteComputer
: gameState.BlackComputer;
if (!isGameOver && gameState.IsVsComputer && sideToMoveEngine is not null)
queue.Queue(() => orchestrator.PlayAsync(gameState));
return Ok(new { result, state });
}
/// <summary>
/// Forfeit a game on behalf of the calling player, handing the win to the opponent.
/// Used when a player abandons a game (e.g. starts a new one mid-game).
/// </summary>
[HttpPost("forfeit")]
public async Task<ActionResult> Forfeit([FromBody] ForfeitDto forfeit)
{
if (!gameStore.TryGet(forfeit.GameId, out var gameState))
return NotFound("Game not found");
if (gameState.IsCheckmate || gameState.IsStalemate || gameState.IsForfeited)
return Ok();
var isWhitePlayer = gameState.WhitePlayerId == forfeit.PlayerId;
var isBlackPlayer = gameState.BlackPlayerId == forfeit.PlayerId;
if (!isWhitePlayer && !isBlackPlayer)
return StatusCode(403, "You are not a player in this game.");
gameState.IsForfeited = true;
gameState.Winner = isWhitePlayer ? PieceColor.Black : PieceColor.White;
await chessHub.Clients.Group(gameState.GameId.ToString())
.SendAsync("ReceiveGameOver", gameState.GameId.ToString(), gameState.Winner.ToString(), "forfeit");
gameStore.ScheduleRemove(gameState.GameId, _gameCleanupTimeout);
return Ok();
}
/// <summary>
/// Export a game as PGN (works while the game is still in memory after it ends).
/// </summary>
[HttpGet("{gameId}/pgn")]
public ActionResult GetPgn(Guid gameId)
{
if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found");
return Content(gameState.ToPgn(), "application/x-chess-pgn");
}
/// <summary>
@@ -200,7 +332,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
[HttpGet("{gameId}/legalMoves/{pieceId}")]
public ActionResult GetLegalMoves(Guid gameId, string pieceId)
{
if (!_games.TryGetValue(gameId, out var gameState))
if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found");
var moves = chessService.GetLegalMovesForPiece(gameState, pieceId);
@@ -214,7 +346,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
[HttpGet("{gameId}/legalMoves")]
public ActionResult GetAllLegalMoves(Guid gameId)
{
if (!_games.TryGetValue(gameId, out var gameState))
if (!gameStore.TryGet(gameId, out var gameState))
return NotFound("Game not found");
var allMoves = chessService.GetAllLegalMoves(gameState)
@@ -226,34 +358,4 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
return Ok(allMoves);
}
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
{
if (_gameRemovalTasks.ContainsKey(id))
{
_gameRemovalTasks[id] = Task.Run(async () =>
{
await Task.Delay(delay);
if (_games[id].Computer is not null)
await _games[id].Computer!.DisposeAsync();
_games.Remove(id, out _);
_gameRemovalTasks.Remove(id, out _);
});
return;
}
_gameRemovalTasks.TryAdd(id, Task.Run(async () =>
{
await Task.Delay(delay);
if (_games[id].Computer is not null)
await _games[id].Computer!.DisposeAsync();
_games.Remove(id, out _);
_gameRemovalTasks.Remove(id, out _);
}));
}
}
+1 -7
View File
@@ -1,5 +1,4 @@
using JoshHeaps.Net.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR;
namespace JoshHeaps.Net.Hubs;
@@ -11,11 +10,6 @@ public class ChessHub : Hub
await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
}
public async Task MoveMade(string gameId, MoveDto moveDto, MoveResultDto moveResult)
{
await Clients.OthersInGroup(gameId).SendAsync("ReceiveMoveUpdate", gameId, moveDto, moveResult);
}
public async Task LeaveWebsocketGroup(string gameId)
{
Console.WriteLine($"❌ Leaving group {gameId}");
+14
View File
@@ -4,6 +4,8 @@
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
@@ -27,4 +29,16 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.7" />
</ItemGroup>
<!-- Builds the native engine and refreshes Resources\chess_engine.dll (via its post-build copy)
only inside Visual Studio, which has the C++ tools. The dotnet CLI can't build a .vcxproj,
so this is skipped by `dotnet build`/`dotnet publish` (incl. the Linux CI, which deploys the
libchess_engine.so instead). ReferenceOutputAssembly=false: build-ordering only, not a
managed reference to the native DLL. -->
<ItemGroup Condition="'$(BuildingInsideVisualStudio)' == 'true'">
<ProjectReference Include="..\native\chess_engine\chess_engine\chess_engine.vcxproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<Private>false</Private>
</ProjectReference>
</ItemGroup>
</Project>
+12
View File
@@ -0,0 +1,12 @@
namespace JoshHeaps.Net.Models;
public class BlogPost
{
public long Id { get; set; }
public string Slug { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public DateTime PublishedDate { get; set; }
public string Summary { get; set; } = string.Empty;
public List<string> Tags { get; set; } = [];
public string HtmlContent { get; set; } = string.Empty;
}
+7
View File
@@ -0,0 +1,7 @@
namespace JoshHeaps.Net.Models;
public class ForfeitDto
{
public Guid GameId { get; set; }
public Guid PlayerId { get; set; }
}
+33 -1
View File
@@ -1,4 +1,5 @@
using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Models;
@@ -28,10 +29,25 @@ public class GameState
public bool IsCheck { get; set; }
public bool IsCheckmate { get; set; }
public bool IsStalemate { get; set; }
public bool IsThreefoldRepetition { get; set; }
public bool IsForfeited { get; set; } = false;
// The color that won, when the game ended by forfeit (null while the game is live).
public PieceColor? Winner { get; set; }
// Keep a history of moves if desired
public List<string> MoveHistory { get; set; }
// Position keys (FEN placement/side/castling/en-passant) for threefold-repetition detection.
public List<string> PositionHistory { get; set; }
// Moves in standard algebraic notation (SAN), in order, for PGN export.
public List<string> SanHistory { get; set; }
// Half-moves since the last capture or pawn move (the FEN 50-move clock). Also tells
// us how many trailing PositionHistory entries belong to the current repetition window.
public int HalfmoveClock { get; set; }
// A list of all pieces to quickly reference them (optional but convenient).
// Alternatively, you can iterate the Board array.
public List<ChessPiece> Pieces { get; set; }
@@ -42,8 +58,22 @@ public class GameState
public Guid BlackPlayerId { get; set; }
public bool IsVsComputer { get; set; } = false;
public bool IsComputerVsComputer { get; set; } = false;
public Stockfish? Computer { get; set; }
// The engine playing each side (null for a human). In a human-vs-computer game only the
// computer's side is set; the orchestrator picks the engine for whoever is to move.
public IChessEngine? WhiteComputer { get; set; }
public IChessEngine? BlackComputer { get; set; }
// Which engine implementation each side uses — lets game-over handling know which side(s)
// are the learning engine, and lets spectators see who is playing.
public ChessEngineKind WhiteEngineKind { get; set; }
public ChessEngineKind BlackEngineKind { get; set; }
// Native per-game training accumulator (nint.Zero when this game isn't training the
// learned engine). The engine records each played position into it and applies the
// result on game over.
public nint Trainer { get; set; }
// optional: convenience
public bool IsOpen => !WhiteJoined || !BlackJoined;
@@ -54,5 +84,7 @@ public class GameState
Board = new ChessPiece[8, 8];
Pieces = [];
MoveHistory = [];
PositionHistory = [];
SanHistory = [];
}
}
+61
View File
@@ -0,0 +1,61 @@
using System.Linq;
namespace JoshHeaps.Net.Models;
/// <summary>
/// A single piece as sent to the client. Captured pieces keep their original
/// type and color; their position is meaningless and omitted.
/// </summary>
public record ChessPieceDto(string Id, PieceType Type, PieceColor Color, int Row, int Col, bool HasMoved);
/// <summary>
/// The full board state pushed to clients. The same shape is returned by the
/// state endpoint, the move endpoint, and every SignalR move broadcast, so the
/// client always renders from one authoritative payload instead of re-fetching.
/// </summary>
public record GameStateDto(
Guid GameId,
string CurrentPlayer,
bool IsCheck,
bool IsCheckmate,
bool IsStalemate,
bool IsThreefoldRepetition,
string? EnPassantTarget,
bool WhiteCanCastleKingside,
bool WhiteCanCastleQueenside,
bool BlackCanCastleKingside,
bool BlackCanCastleQueenside,
IReadOnlyList<ChessPieceDto> Pieces,
IReadOnlyList<ChessPieceDto> CapturedPieces,
IReadOnlyList<string> MoveHistory,
// Moves in standard algebraic notation, for the move-list panel.
IReadOnlyList<string> SanHistory,
// Monotonic ply counter the client uses to drop stale or echoed updates.
int Version);
public static class GameStateMapper
{
public static GameStateDto ToDto(this GameState gameState) => new(
gameState.GameId,
gameState.CurrentPlayer.ToString(),
gameState.IsCheck,
gameState.IsCheckmate,
gameState.IsStalemate,
gameState.IsThreefoldRepetition,
gameState.EnPassantTarget?.ToString(),
gameState.WhiteCanCastleKingside,
gameState.WhiteCanCastleQueenside,
gameState.BlackCanCastleKingside,
gameState.BlackCanCastleQueenside,
gameState.Pieces
.Where(p => p.Position.Row >= 0)
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
.ToList(),
gameState.Pieces
.Where(p => p.Position.Row < 0)
.Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved))
.ToList(),
gameState.MoveHistory,
gameState.SanHistory,
gameState.MoveHistory.Count);
}
@@ -0,0 +1,9 @@
namespace JoshHeaps.Net.Models;
/// <summary>
/// A copy of the learned engine's weights for display: midgame and endgame piece-square
/// tables (one 64-entry array per piece, in canonical Pawn..King order, white-relative
/// A1=0..H8=63) plus the feature weights (mobility N/B/R/Q, passed, isolated, doubled,
/// king safety).
/// </summary>
public sealed record LearnedWeightsSnapshot(int[][] Mg, int[][] Eg, int[] Features);
+1
View File
@@ -7,4 +7,5 @@ public class MoveResultDto
public bool IsCheck { get; set; }
public bool IsCheckmate { get; set; }
public bool IsStalemate { get; set; }
public bool IsThreefoldRepetition { get; set; }
}
+49
View File
@@ -0,0 +1,49 @@
@page
@model JoshHeaps.Net.Pages.BlogModel
@{
ViewData["Title"] = "Blog - JoshHeaps.Net";
Layout = "_Layout";
}
<div class="blog-index">
<h1 class="blog-title">Blog</h1>
@if (Model.ActiveTag is not null)
{
<div class="blog-filter">
<span>Filtered by: <strong>@Model.ActiveTag</strong></span>
<a href="/blog">Clear filter</a>
</div>
}
@if (Model.Posts.Count == 0)
{
<p class="blog-empty">No posts yet. Check back soon!</p>
}
<div class="blog-list">
@foreach (var post in Model.Posts)
{
<article class="blog-card">
<a href="/blog/@post.Slug" class="blog-card-link">
<h2 class="blog-card-title">@post.Title</h2>
</a>
<time class="blog-card-date" datetime="@post.PublishedDate.ToString("yyyy-MM-dd")">
@post.PublishedDate.ToString("MMMM d, yyyy")
</time>
<p class="blog-card-summary">@post.Summary</p>
<div class="blog-card-tags">
@foreach (var tag in post.Tags)
{
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
}
</div>
</article>
}
</div>
</div>
@section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/index.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
+26
View File
@@ -0,0 +1,26 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages;
public class BlogModel : PageModel
{
private readonly IBlogService _blogService;
public List<BlogPost> Posts { get; set; } = [];
public string? ActiveTag { get; set; }
public BlogModel(IBlogService blogService)
{
_blogService = blogService;
}
public async Task OnGetAsync(string? tag)
{
ActiveTag = tag;
Posts = tag is not null
? await _blogService.GetPostsByTagAsync(tag)
: await _blogService.GetAllPostsAsync();
}
}
+38
View File
@@ -0,0 +1,38 @@
@page "/blog/{slug}"
@model JoshHeaps.Net.Pages.Blog.PostModel
@{
ViewData["Title"] = Model.Post!.Title + " - JoshHeaps.Net";
Layout = "_Layout";
}
<article class="blog-post">
<a href="/blog" class="blog-back">&larr; Back to blog</a>
<header class="blog-post-header">
<h1 class="blog-post-title">@Model.Post.Title</h1>
<time class="blog-post-date" datetime="@Model.Post.PublishedDate.ToString("yyyy-MM-dd")">
@Model.Post.PublishedDate.ToString("MMMM d, yyyy")
</time>
<div class="blog-post-tags">
@foreach (var tag in Model.Post.Tags)
{
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
}
</div>
</header>
<div class="post-content">
@Html.Raw(Model.Post.HtmlContent)
</div>
</article>
@section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/post.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/prism-tomorrow.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js"></script>
}
+28
View File
@@ -0,0 +1,28 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Blog;
public class PostModel : PageModel
{
private readonly IBlogService _blogService;
public BlogPost? Post { get; set; }
public PostModel(IBlogService blogService)
{
_blogService = blogService;
}
public async Task<IActionResult> OnGetAsync(string slug)
{
Post = await _blogService.GetPostBySlugAsync(slug);
if (Post is null)
return NotFound();
return Page();
}
}
+58
View File
@@ -0,0 +1,58 @@
@page
@model JoshHeaps.Net.Pages.CasinoModel
@{
ViewData["Title"] = "Casino Games";
Layout = "_Layout";
}
<div class="casino-container">
<h1 class="casino-header">Casino Games</h1>
<p class="casino-subtitle">Tools for running table games without doing the math yourself</p>
<div class="games-grid">
<a href="/casino/texasholdem" class="game-card">
<div class="game-icon">🃏</div>
<div class="game-title">Texas Hold'em</div>
<div class="game-description">
A dealer's assistant: seat players around the table, track blinds, chips and
all-ins, then split main and side pots automatically.
</div>
</a>
<a href="/casino/texasholdem/quicksplit" class="game-card">
<div class="game-icon">➗</div>
<div class="game-title">Quick Split</div>
<div class="game-description">
Chop a pot in seconds on your phone — no table setup, just the amount,
how many players are splitting it, and the smallest chip in play.
</div>
</a>
<div class="game-card disabled">
<div class="game-icon">🎲</div>
<div class="game-title">Craps</div>
<div class="game-description">Payout calculator for the table.</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="game-card disabled">
<div class="game-icon">🎡</div>
<div class="game-title">Roulette</div>
<div class="game-description">Bet tracking and payout reference.</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="game-card disabled">
<div class="game-icon">🂡</div>
<div class="game-title">Blackjack</div>
<div class="game-description">Multi-hand dealer tracker.</div>
<div class="coming-soon">Coming soon...</div>
</div>
</div>
<a href="/" class="home-button">← Back to Home</a>
</div>
@section Styles {
<link href="/css/casino/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class CasinoModel : PageModel
{
public void OnGet()
{
}
}
}
@@ -0,0 +1,464 @@
@page
@model JoshHeaps.Net.Pages.Casino.TexasHoldemModel
@{
ViewData["Title"] = "Texas Hold'em Dealer";
Layout = "_Layout";
}
<div id="holdemApp">
<header id="holdemHeader">
<div id="holdemHeaderLeft">
<h1>Texas Hold'em Dealer</h1>
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
</div>
<div id="holdemHeaderActions">
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
<a href="/casino" class="holdemBtn holdemBtnGhost">← Casino</a>
</div>
</header>
<div id="holdemLayout">
<section id="tableSection">
<div id="pokerTableWrap">
<div id="pokerTable">
<div id="tableWatermark" aria-hidden="true">Texas Hold'em</div>
<div id="potBadge">
<span id="potBadgeLabel">Pot</span>
<span id="potBadgeAmount">$0</span>
</div>
</div>
<div id="betsLayer" aria-hidden="true"></div>
<div id="seatsLayer"></div>
</div>
<p id="tableHint" class="tableHint"></p>
<p class="tableHintActions">
<button type="button" id="openRulingsBtn" class="linkBtn">Common rulings</button>
</p>
</section>
<aside id="holdemSidebar">
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatEditorHeader">
<h2 id="seatEditorTitle">Select a seat</h2>
<button id="closeSeatEditorBtn" class="seatSheetClose" aria-label="Done editing this seat">Done</button>
</div>
<div id="seatEditorBody">
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
</div>
</section>
<details class="sidebarPanel" open>
<summary>Table Setup</summary>
<div class="panelBody">
<label class="fieldRow">
<span>Seats at table</span>
<input type="number" id="seatCountInput" min="2" max="24" value="10" />
</label>
<label class="fieldRow">
<span>Blinds move</span>
<select id="directionSelect">
<option value="clockwise" selected>Clockwise</option>
<option value="counterclockwise">Counterclockwise</option>
</select>
</label>
<label class="fieldRow">
<span>Dedicated dealer (not a seat)</span>
<input type="checkbox" id="dedicatedDealerToggle" />
</label>
<p class="fieldHint">Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.</p>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
<input type="number" id="smallBlindInput" min="0" step="0.5" />
</label>
<label class="fieldRow">
<span>Big blind</span>
<input type="number" id="bigBlindInput" min="0" step="0.5" />
</label>
</div>
<label class="fieldRow">
<span>Starting stack ($)</span>
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<details class="subPanel">
<summary>Without live betting</summary>
<div class="subPanelBody">
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
</label>
<p class="fieldHint">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
</div>
</details>
</div>
</details>
<details class="sidebarPanel">
<summary>Session</summary>
<div class="panelBody">
<h3 class="panelSubhead">Standings</h3>
<div class="standingsTableWrap">
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
<span class="standingsInHead">In</span>
<span class="standingsWonHead">Won</span>
<span>Net</span>
<span title="Chips in front of them, less the cash they handed over">Cash out</span>
</div>
<div id="standingsList"></div>
</div>
</div>
<p id="standingsEmptyHint" class="fieldHint">Everyone who has bought in shows up here, with what they'd be owed if the night ended now.</p>
<div id="bankReconcile" class="bankReconcile" hidden>
<div class="bankReconcileRow">
<span>Cash taken</span>
<span id="bankCashIn"></span>
</div>
<div class="bankReconcileRow">
<span>Chips in play</span>
<span id="bankChipsInPlay"></span>
</div>
<p id="bankReconcileVerdict" class="bankReconcileVerdict"></p>
</div>
<div id="settleUp" hidden>
<h3 class="panelSubhead">Settle up</h3>
<ul id="settleUpList" class="settleUpList"></ul>
<p id="settleUpNote" class="fieldHint"></p>
</div>
<h3 class="panelSubhead">Hands</h3>
<div id="handHistoryList"></div>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
<div class="handHistoryActions">
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
</div>
</div>
</details>
<details class="sidebarPanel">
<summary>Chip Legend</summary>
<div class="panelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
</div>
</details>
<details class="sidebarPanel">
<summary>Table Notes</summary>
<div class="panelBody">
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div>
</details>
</aside>
</div>
</div>
<template id="seatTemplate">
<div class="seat" draggable="true">
<div class="seatChairNumber"></div>
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<div class="seatPuckName"></div>
<div class="seatPuckCards"></div>
<div class="seatPuckTotal"></div>
<div class="seatPuckStack"></div>
</div>
</template>
<template id="seatEditorTemplate">
<div class="seatEditorForm">
<label class="fieldRow">
<span>Name</span>
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
</label>
<div class="seatCardToggles">
<label class="inHandToggle" title="This seat is playing the current hand">
<input type="checkbox" class="inHandCheckbox" />
<span>In hand</span>
</label>
<label class="foldToggle" title="Folded">
<input type="checkbox" class="foldCheckbox" />
<span>Fold</span>
</label>
</div>
<div class="seatCards">
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
</div>
<label class="fieldRow stackRow">
<span>Stack</span>
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
</label>
<div class="seatBuyIns" hidden></div>
<div class="seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
</div>
</template>
<template id="chipLegendRowTemplate">
<div class="chipLegendRow">
<input type="color" class="chipColorInput" aria-label="Chip colour" />
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip value" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
</div>
</template>
<template id="chipCountTemplate">
<label class="chipCountRow">
<span class="chipSwatch"></span>
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
</label>
</template>
<template id="customFieldTemplate">
<div class="customFieldRow">
<input type="text" class="customFieldLabel" placeholder="Label" maxlength="20" />
<input type="text" class="customFieldValue" placeholder="Value" maxlength="40" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeFieldBtn">×</button>
</div>
</template>
<template id="standingsRowTemplate">
<div class="standingsRow">
<span class="standingsName"></span>
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
<span class="standingsCashOut"></span>
</div>
</template>
<template id="handHistoryRowTemplate">
<div class="handHistoryRow">
<div class="handHistoryRowHeader">
<span class="handHistoryHandNumber"></span>
<span class="handHistoryPot"></span>
<span class="handHistoryTime"></span>
</div>
<div class="handHistoryPayouts"></div>
</div>
</template>
<div id="addChipsOverlay" hidden>
<div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="addChipsHeader">
<h2 id="addChipsTitle"></h2>
<span id="addChipsSubtitle"></span>
</div>
<div id="addChipsQuick"></div>
<div class="addChipsEntry">
<span class="addChipsCurrency">$</span>
<input type="text" id="addChipsInput" inputmode="decimal" autocomplete="off" aria-label="Amount to add" />
</div>
<div class="addChipsActions">
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
<button id="addChipsCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
</div>
<div id="rulingsOverlay" hidden>
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="rulingsHeader">
<h2 id="rulingsTitle">Common rulings</h2>
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<p class="rulingsIntro">
The conventions most rooms follow, for the calls that come up mid-hand.
Your house rules always win — and settle a dispute before the next hand,
not during it.
</p>
<section class="ruling">
<h3>Redeal it — misdeal</h3>
<ul>
<li>The first card goes to the wrong seat.</li>
<li>A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.</li>
<li>Two or more boxed cards turn up on the initial deal.</li>
<li>Either of the first two cards is exposed by dealer error.</li>
</ul>
</section>
<section class="ruling">
<h3>A board card turned too early</h3>
<p>
If the fourth board card gets turned before the betting round is finished,
that card is out of play for the round. Finish the betting, then burn and
turn what would have been the fifth card in the fourth card's place.
</p>
</section>
<section class="ruling">
<h3>Acting out of turn</h3>
<p>
A one-off is usually just backed up if no one has acted behind them.
Doing it repeatedly is what earns a penalty.
</p>
</section>
<section class="ruling">
<h3>A short all-in doesn't reopen the betting</h3>
<p>
Somebody shoving in less than a full raise — say $3 more when the last
raise was $8 — does not give the players behind them another go. Anyone
who has already acted owes the difference or folds; they don't get to
raise again because a short stack happened to land between them.
</p>
<p>
A player who hadn't acted yet keeps full rights and may still raise, and
the minimum raise stays measured off the last <em>full</em> raise, not
off the short one. The action bar follows this for you.
</p>
</section>
<section class="ruling">
<h3>Chips nobody called come back</h3>
<p>
Bet more than anyone was able to call and the excess is returned to you
before the showdown — it was never contested, so it isn't part of the
pot. The pot breakdown shows it as "uncalled" rather than pretending it's
a side pot you could win.
</p>
</section>
<section class="ruling">
<h3>One big chip, nothing said</h3>
<p>
The most common argument at a home table. A player facing a bet who
pushes out a single oversized chip without saying anything has
<em>called</em> — not raised, however big the chip is. To raise with one
chip you have to say "raise" before it lands; say it without an amount
and the raise is the most that chip allows.
</p>
<p>
Facing no bet, the same silent chip is a bet of the whole chip. And a
clear verbal declaration in turn is binding either way — if words and
chips disagree, whichever came first is the action.
</p>
</section>
<section class="ruling">
<h3>A raise has to be a full raise</h3>
<p>
At least as much again as the last raise, or the big blind if there
hasn't been one. Anything between a call and that minimum isn't a raise
a player can make — it's a call, unless they're putting their last chips
in. The raise pad says so if you type one, and won't stop you: your
ruling beats the arithmetic.
</p>
</section>
<section class="ruling">
<h3>The odd chip in a split pot</h3>
<p>
When a chopped pot won't divide evenly, the odd chip goes to the first
player left of the button, not to whoever the dealer happened to tick
first. Pot payouts here order the winners that way, so the leftover lands
on the right seat every time.
</p>
</section>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
home games take their lead from.
</p>
</div>
</div>
<div id="seatMenuOverlay" hidden>
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatMenuHeader">
<h2 id="seatMenuTitle"></h2>
<span id="seatMenuSubtitle"></span>
</div>
<div id="seatMenuActions"></div>
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
<div id="cardPickerOverlay" hidden>
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="cardPickerHeader">
<h2 id="cardPickerTitle"></h2>
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<div id="cardPickerSlots"></div>
<div id="cardPickerGrid"></div>
</div>
</div>
<div id="actionBar" aria-live="polite" hidden></div>
<div id="toastHost" aria-live="polite"></div>
<div id="confirmOverlay" hidden>
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
<h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p>
<div class="confirmActions">
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
</div>
</div>
</div>
<div id="potWizardOverlay" hidden>
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
<div class="potWizardHeader">
<h2 id="potWizardTitle">Resolve the Pot</h2>
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
</div>
<div id="potWizardBody"></div>
</div>
</div>
@section Styles {
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/settleUp.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/addChips.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/modal.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Casino
{
public class TexasHoldemModel : PageModel
{
public void OnGet()
{
}
}
}
@@ -0,0 +1,95 @@
@page
@model JoshHeaps.Net.Pages.Casino.TexasHoldem.QuickSplitModel
@{
ViewData["Title"] = "Quick Split";
Layout = "_Layout";
}
<div id="splitApp">
<header id="splitHeader">
<h1>Quick Split</h1>
<p class="splitSubtitle">Chop a pot without setting up a table.</p>
</header>
<div id="modeToggle" class="chipRow" role="group" aria-label="How the money went in">
<button type="button" id="modeEvenBtn" class="choiceChip selected">Everyone matched</button>
<button type="button" id="modeUnevenBtn" class="choiceChip">Someone's all-in</button>
</div>
<div id="splitResult" aria-live="polite">
<div id="splitResultLabel">Each player gets</div>
<div id="splitResultAmount">$0.00</div>
<div id="splitOddNote" hidden></div>
<div id="splitDustNote" hidden></div>
</div>
<div id="unevenResult" hidden aria-live="polite">
<div id="unevenPots"></div>
<p id="unevenEmpty" class="unevenEmpty">Enter what each player put in.</p>
</div>
<section class="splitField unevenOnly" hidden>
<span class="splitLabel">What each player put in</span>
<div id="unevenRows"></div>
<button type="button" id="addPlayerBtn" class="splitBtn splitBtnGhost">+ Add player</button>
</section>
<section class="splitField evenOnly">
<label for="potInput" class="splitLabel">Pot</label>
<div class="potInputWrap">
<span class="potCurrency">$</span>
<input type="text" id="potInput" inputmode="decimal" autocomplete="off"
placeholder="0" aria-label="Pot amount" />
<button type="button" id="clearPotBtn" class="clearPotBtn" aria-label="Clear pot amount" hidden>✕</button>
</div>
</section>
<section class="splitField evenOnly">
<span class="splitLabel" id="waysLabel">Split between</span>
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
</section>
<section class="splitField evenOnly">
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
</section>
<div id="splitActions">
<button type="button" id="resetSplitBtn" class="splitBtn splitBtnGhost">Clear</button>
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
</div>
<p class="splitFootnote evenOnly">
If somebody was all-in for less than the others, switch to
<strong>Someone's all-in</strong> above.
</p>
<p class="splitFootnote unevenOnly" hidden>
Pots are listed with who can win each. Award them by hand strength, best hand
first. Nothing here is saved — the <a href="/casino/texasholdem">full table</a>
keeps a log.
</p>
</div>
<template id="unevenRowTemplate">
<div class="unevenRow">
<span class="unevenRowLabel"></span>
<div class="unevenAmountWrap">
<span class="unevenCurrency">$</span>
<input type="text" class="unevenAmount" inputmode="decimal" autocomplete="off" placeholder="0" />
</div>
<button type="button" class="unevenFoldBtn" aria-pressed="false">Folded</button>
<button type="button" class="unevenRemoveBtn" aria-label="Remove player">✕</button>
</div>
</template>
@section Styles {
<link href="/css/casino/quicksplit.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/splitCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/quickSplit.js?v=@ViewData["cssVersion"]"></script>
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Casino.TexasHoldem
{
public class QuickSplitModel : PageModel
{
public void OnGet()
{
}
}
}
+61 -10
View File
@@ -6,7 +6,14 @@
}
<div id="chessContainer">
<div id="boardContainer">
<section id="boardArea">
<div class="playerBar" id="topPlayerBar">
<span class="playerDot black"></span>
<span class="playerName">Opponent</span>
<div class="capturedTray" id="captured-top"></div>
<span class="advantage" id="advantage-top"></span>
</div>
<div id="chessBoard">
<!-- Placeholder squares -->
@for (int i = 0; i < 64; i++)
@@ -14,16 +21,41 @@
<div id="square-@i" class="chessSquare @( (i + i / 8) % 2 == 0 ? "light" : "dark" )"></div>
}
</div>
</div>
<div id="textContainer" class="sideContent">
<h1>Chess</h1>
<p>Click a button to start a game :)</p>
</div>
<div class="playerBar" id="bottomPlayerBar">
<span class="playerDot white"></span>
<span class="playerName">You</span>
<div class="capturedTray" id="captured-bottom"></div>
<span class="advantage" id="advantage-bottom"></span>
</div>
</section>
<div id="buttonContainer" class="sideContent">
<button id="startGameBtn" onclick="startNewGame()">Start New Game</button>
<button id="startCPUGame" onclick="startCPUGame()">Vs CPU</button>
<aside id="gamePanel">
<header class="panelHeader">
<span class="panelLogo">&#9822;</span>
<h1>Chess</h1>
<button id="menuClose" class="iconBtn" aria-label="Close menu" onclick="closeMenu()">&times;</button>
</header>
<div id="moveList">
<p class="movePlaceholder">Moves will appear here once a game begins.</p>
</div>
<div id="statusLine">Start a game to play.</div>
<div id="panelButtons">
<button id="startGameBtn" class="btn btn-primary" onclick="startNewGame()">New Game</button>
<button id="startCPUGame" class="btn btn-secondary" onclick="startCPUGame()">Play vs CPU</button>
<button id="copyPgnBtn" class="btn btn-ghost" onclick="copyPgn()" style="display: none">Copy PGN</button>
<a id="watchLink" href="/watch">Watch other games &rarr;</a>
</div>
</aside>
<div id="menuBackdrop" onclick="closeMenu()"></div>
<div id="mobileBar">
<span id="mobileStatus">Start a game to play.</span>
<button id="menuToggle" class="btn btn-primary" onclick="toggleMenu()">Menu</button>
</div>
</div>
@@ -57,12 +89,31 @@
</div>
</div>
<div id="colorModal" style="display: none;">
<p>Play as:</p>
<div id="colorButtonContainer">
<button onclick="selectColor('white')">White</button>
<button onclick="selectColor('black')">Black</button>
<button onclick="selectColor('random')">Random</button>
</div>
</div>
@section Scripts {
<script src="~/js/signalr/signalr.min.js"></script>
<script src="~/js/ChessScripts/chessLogic.js"></script>
<script src="~/js/ChessScripts/GameState.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessUtils.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessAPI.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessSignalR.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessInteractions.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessBoard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/ChessModals.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/ChessScripts/chessMain.js?v=@ViewData["cssVersion"]"></script>
}
@section Styles {
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
<link rel="stylesheet" href="~/css/chess/site.css?v=@ViewData["cssVersion"]" />
}
+89 -67
View File
@@ -1,95 +1,117 @@
@page
@page
@model JoshHeaps.Net.Pages.IndexModel
@{
ViewData["Title"] = "JoshHeaps.Net";
Layout = "_Layout";
}
<header id="Header">
<a class="headerOption" onclick="showClickedContents('projects')">./ Projects</a>
<a class="headerOption" onclick="showClickedContents('demos')">./ Demos</a>
<a class="headerOption" onclick="showClickedContents('contact')">./ Contact me</a>
</header>
<div id="WelcomeMessage"></div>
<div id="ProjectsBox">
<div id="ChessProject" class="projectContainer">
<div class="displayBox diagonal-section-left">
<a target="_blank" href="https://github.com/JoshHeaps/OnlineChess">
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/>
</a>
</div>
<div id="ChessText" class="projectTextRight">
<h2 id="ChessTitle" class="projectHeaderRight projectHeader">Chess</h2>
<p id="ChessDescription" class="projectDescriptionRight projectDescription">
As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. This project is a work in progress, as currently it can only be played locally. It was built in C# on .NET 8.0, using winforms, because I like a challenge. I plan to add online multiplayer functionality in the future.
</p>
</div>
<nav id="Header">
<span class="site-name">josh heaps</span>
<div class="nav-links">
<a class="nav-link" onclick="showClickedContents('projects')">Projects</a>
<a class="nav-link" onclick="showClickedContents('demos')">Demos</a>
<a class="nav-link" onclick="showClickedContents('blog')">Blog</a>
<a class="nav-link" onclick="showClickedContents('contact')">Contact</a>
</div>
</nav>
<div id="CompilerProject" class="projectContainer">
<div id="CompilerText" class="projectTextLeft">
<h2 id="CompilerTitle" class="projectHeaderLeft projectHeader">Compiler</h2>
<p id="CompilerDescription" class="projectDescriptionLeft projectDescription">
I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.
</p>
</div>
<div class="displayBox diagonal-section-right">
<a target="_blank" href="https://github.com/JoshHeaps/CILCompiler">
<img id="CompilerImage" class="projectImage projectImageRight" src="/images/CompilerDemo.png" title="Compiler Input and Output"/>
<section id="Hero">
<div id="WelcomeMessage"></div>
</section>
<section id="ProjectsBox">
<h2 class="section-title">Projects</h2>
<div class="projects-grid">
<article class="project-card">
<a href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank" class="project-image-link">
<img class="projectImage" src="/images/Chess.jpg" alt="Chessboard" />
</a>
</div>
</div>
</div>
<div class="project-info">
<h3 class="project-title">Chess</h3>
<p class="project-description">As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)</p>
<a class="project-link" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank">View on GitHub &rarr;</a>
</div>
</article>
<div id="DemosBox" class="displayBox">
<h2 id="DemoHeader">Demos</h2>
<article class="project-card">
<a href="https://github.com/JoshHeaps/CILCompiler" target="_blank" class="project-image-link">
<img class="projectImage" src="/images/CompilerDemo.png" alt="Compiler Input and Output" />
</a>
<div class="project-info">
<h3 class="project-title">Compiler</h3>
<p class="project-description">I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.</p>
<a class="project-link" href="https://github.com/JoshHeaps/CILCompiler" target="_blank">View on GitHub &rarr;</a>
</div>
</article>
<article class="project-card">
<a href="https://media.joshheaps.net" target="_blank" class="project-image-link">
<img class="projectImage" src="/images/CloudStorageDemo.png" alt="Cloud Storage Screenshot" />
</a>
<div class="project-info">
<h3 class="project-title">Image Cloud Storage</h3>
<p class="project-description">My wife and I LOVE to take pictures and videos of our kids, but we do not like paying for extra cloud storage. I made this for my family so we can upload pictures to my server, share them with each other, and now we don't need to worry about how much space we're using.</p>
<a class="project-link" href="https://media.joshheaps.net" target="_blank">View Live Demo &rarr;</a>
</div>
</article>
</div>
</section>
<section id="DemosBox">
<h2 class="section-title">Demos</h2>
<div id="buttonWrapper">
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/chess'">
Play Chess
</button>
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/particles'">
Life
</button>
<button id="MoreFiller" class="demoButton">
More coming soon...
</button>
<button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
<button class="demoButton" onclick="window.location.href='/watch'">Watch Live Chess</button>
<button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
<button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
<button class="demoButton" onclick="window.location.href='/casino'">Casino Games</button>
<button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
<button id="MoreFiller" class="demoButton">More coming soon...</button>
</div>
</div>
</section>
<section id="BlogBox">
<h2 class="section-title">Latest Posts</h2>
<div id="blogPostsList">
@foreach (var post in Model.LatestPosts)
{
<div class="blog-teaser-item">
<a class="blog-teaser-title" href="/blog/@post.Slug">@post.Title</a>
<span class="blog-teaser-date">@post.PublishedDate.ToString("MMMM d, yyyy")</span>
<p class="blog-teaser-summary">@post.Summary</p>
</div>
}
</div>
<a class="blog-view-all" href="/blog">View all posts &rarr;</a>
</section>
<footer id="Contacts">
<svg viewBox="0 0 16 16" class="social-icon">
<a id="GithubLink" class="social-link" href="https://github.com/JoshHeaps" target="_blank">
<a class="social-link" href="https://github.com/JoshHeaps" target="_blank">
<svg viewBox="0 0 16 16" class="social-icon">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
<rect width="100%" height="100%" fill="transparent" />
</a>
</svg>
<svg viewBox="0 0 24 24" class="social-icon">
<a id="LinkedinLink" class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
</svg>
</a>
<a class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
<svg viewBox="0 0 24 24" class="social-icon">
<path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"></path>
<rect width="100%" height="100%" fill="transparent" />
</a>
</svg>
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
<a id="GmailLink" class="social-link" href="mailto:[email protected]">
</svg>
</a>
<a class="social-link" href="mailto:[email protected]">
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
<path d="m0 3.23863636v2.72727273l3.12784091 3.02727273 3.69034091 2.08636368.68181818-4.59034095-.68181818-4.27329546-1.90909091-1.43181818c-2.02329546-1.51704546-4.90909091-.07329545-4.90909091 2.45454545"/>
<path d="m23.1818182 2.21590909-.6818182 4.32954546.6818182 4.53409095 3.3494318-1.65852277 3.46875-3.45511364v-2.72727273c0-2.5278409-2.8857955-3.97159091-4.9090909-2.45454545z"/>
<path d="m2.04545455 22.6704545h4.77272727v-11.590909l-6.81818182-5.11363641v14.65909091c0 1.1301136.91534091 2.0454545 2.04545455 2.0454545"/>
<path d="m23.1818182 22.6704545h4.7727273c1.1301136 0 2.0454545-.9153409 2.0454545-2.0454545v-14.65909091l-6.8181818 5.11363641z"/>
<path d="m15 8.35227273-8.18181818-6.13636364v8.86363641l8.18181818 6.1363636 8.1818182-6.1363636v-8.86363641z"/>
<rect width="100%" height="100%" fill="transparent" />
</a>
</svg>
</svg>
</a>
</footer>
@section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/site.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/project.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/imageLeftProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/imageRightProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/blog.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@@ -97,4 +119,4 @@
<script src="/js/site.js"></script>
<script src="/js/utils.js"></script>
<script src="/js/opacityUpdateObserver.js"></script>
}
}
+8 -3
View File
@@ -1,12 +1,17 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class IndexModel() : PageModel
public class IndexModel(IBlogService blogService) : PageModel
{
public void OnGet()
{
public List<BlogPost> LatestPosts { get; set; } = [];
public async Task OnGetAsync()
{
var allPosts = await blogService.GetAllPostsAsync();
LatestPosts = allPosts.Take(3).ToList();
}
}
}
+54
View File
@@ -0,0 +1,54 @@
@page
@model JoshHeaps.Net.Pages.MemoryLaneModel
@{
ViewData["Title"] = "Memory Lane";
Layout = "_Layout";
}
<div class="memory-lane-container">
<h1 class="memory-lane-header">Memory Lane</h1>
<p class="memory-lane-subtitle">Special moments and celebrations from our life together</p>
<div class="events-grid">
<a href="/memoryLane/fifthTempleAnniversary" class="event-card">
<div class="event-icon">💝</div>
<div class="event-title">Our 5th Sealing Anniversary</div>
<div class="event-description">
Celebrating the fifth year of our eternal journey together
</div>
</a>
<div class="event-card disabled">
<div class="event-icon">🎂</div>
<div class="event-title">Birthdays & Births</div>
<div class="event-description">
Welcoming new life and celebrating another year
</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="event-card disabled">
<div class="event-icon">🌟</div>
<div class="event-title">Milestones</div>
<div class="event-description">
Big achievements and unforgettable moments
</div>
<div class="coming-soon">Coming soon...</div>
</div>
<div class="event-card disabled">
<div class="event-icon">✨</div>
<div class="event-title">More Memories</div>
<div class="event-description">
Even more special occasions and celebrations
</div>
<div class="coming-soon">Coming soon...</div>
</div>
</div>
<a href="/" class="home-button">← Back to Home</a>
</div>
@section Styles {
<link href="/css/memoryLane/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
+12
View File
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class MemoryLaneModel() : PageModel
{
public void OnGet()
{
}
}
}
@@ -0,0 +1,69 @@
@page
@model JoshHeaps.Net.Pages.MemoryLane.FifthTempleAnniversary
@{
ViewData["Title"] = "Happy 5th Anniversary, Morgan!";
Layout = "_Layout";
}
<div class="anniversary-container">
<h1 class="anniversary-header">Happy 5th Anniversary, Morgan!</h1>
<div id="WelcomeMessage"></div>
<div class="hearts">💖 💕 💗</div>
<div class="photo-gallery">
<div class="photo-frame">
<div class="photo-wrapper">
<img src="/images/Wife Images/45053.jpeg" alt="Morgan with our beautiful baby">
</div>
<div class="photo-caption">The most amazing mother to our precious child</div>
</div>
<div class="photo-frame">
<div class="photo-wrapper">
<img src="/images/Wife Images/45054.jpeg" alt="Josh and Morgan together">
</div>
<div class="photo-caption">My best friend and eternal companion</div>
</div>
</div>
<div class="love-message">
<h2>Five Years of Forever</h2>
<p>
Five years ago today, on October 2nd, 2020, I knelt across the altar from you in the temple
and we were sealed for time and all eternity. It was the best decision I ever made.
</p>
<p>
Morgan, you are everything I could have ever hoped for and so much more. You're not just
my wife—you're my best friend, my partner in every adventure, and the love of my life.
</p>
<p>
You make me laugh every single day. Your kindness touches everyone around you. Your
strength amazes me. Your beauty, inside and out, takes my breath away. Watching you
become a mother has shown me depths of love and dedication I never knew existed.
</p>
<p>
Thank you for five incredible years of love, laughter, growth, and joy. Thank you for
choosing me, for believing in me, and for building this beautiful life with me.
</p>
<p>
Here's to five years down and an eternity to go. I love you more today than yesterday,
and I'll love you even more tomorrow.
</p>
<p style="font-size: 1.5rem; margin-top: 2rem; color: #764ba2;">
<strong>I love you, Morgan. Forever and always. 💖</strong>
</p>
</div>
<div class="hearts">💝 💞 💓</div>
</div>
@section Styles {
<link href="/css/anniversary/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="/js/anniversary/page.js"></script>
<script src="/js/utils.js"></script>
}
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.MemoryLane
{
public class FifthTempleAnniversary() : PageModel
{
public void OnGet()
{
}
}
}
+61
View File
@@ -0,0 +1,61 @@
@page
@model JoshHeaps.Net.Pages.WatchModel
@{
Layout = "_Layout";
ViewData["Title"] = "Watch Chess";
}
<div id="watchHeader">
<h1>Live Chess</h1>
<p id="watchStatus">Loading games…</p>
<div id="watchControls">
<fieldset class="enginePicker">
<legend>White</legend>
<select id="whiteEngine">
<option value="custom">Custom (v1)</option>
<option value="customLearned" selected>Custom (Learned)</option>
<option value="stockfish">Stockfish</option>
</select>
<select id="whiteSkill">
@for (int i = 1; i <= 20; i++)
{
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
}
</select>
</fieldset>
<fieldset class="enginePicker">
<legend>Black</legend>
<select id="blackEngine">
<option value="custom" selected>Custom (v1)</option>
<option value="customLearned">Custom (Learned)</option>
<option value="stockfish">Stockfish</option>
</select>
<select id="blackSkill">
@for (int i = 1; i <= 20; i++)
{
<option value="@i" @(i == 4 ? "selected" : "")>@i</option>
}
</select>
</fieldset>
<button id="startCpuVsCpu" onclick="Spectate.startCpuGame()">Watch CPU vs CPU</button>
<fieldset class="enginePicker">
<legend>Auto-train games</legend>
<input id="autoTrainCount" type="number" min="0" max="16" step="1" />
<button id="applyAutoTrain" onclick="Spectate.setAutoTrainCount()">Apply</button>
</fieldset>
</div>
<a id="backToPlay" href="/chess">← Play a game</a>
<a id="viewWeights" href="/weights">View learned weights →</a>
</div>
<div id="gamesFeed"></div>
@section Scripts {
<script src="~/js/signalr/signalr.min.js"></script>
<script src="~/js/ChessScripts/Spectate.js"></script>
}
@section Styles {
<link rel="stylesheet" href="~/css/chess/game.css?v=@ViewData["cssVersion"]" />
<link rel="stylesheet" href="~/css/chess/spectate.css?v=@ViewData["cssVersion"]" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class WatchModel : PageModel
{
public void OnGet()
{
}
}
}
+44
View File
@@ -0,0 +1,44 @@
@page
@model JoshHeaps.Net.Pages.WeightsModel
@{
Layout = "_Layout";
ViewData["Title"] = "Learned Weights";
}
<div id="weightsHeader">
<h1>Learned Piece-Square Weights</h1>
<p id="weightsStatus">Where the learned engine thinks each piece belongs.</p>
<div id="weightsControls">
<div class="heatLegend">
<span>low</span>
<span class="legendBar"></span>
<span>high</span>
</div>
<button id="refreshWeights" onclick="Weights.refresh()">Refresh</button>
</div>
<a id="backToWatch" href="/watch">← Watch / train</a>
</div>
<section class="weightsSection">
<h2>Feature weights</h2>
<p class="sectionHint">Learned value of each contextual feature (per normalized unit). Mobility is per piece type; passed pawns are endgame-weighted, king safety midgame-weighted.</p>
<div id="featureWeights" class="featurePanel"></div>
</section>
<section class="weightsSection">
<h2>Midgame tables</h2>
<div id="weightsGridMg" class="weightsGrid"></div>
</section>
<section class="weightsSection">
<h2>Endgame tables</h2>
<div id="weightsGridEg" class="weightsGrid"></div>
</section>
@section Scripts {
<script src="~/js/ChessScripts/Weights.js"></script>
}
@section Styles {
<link rel="stylesheet" href="~/css/chess/weights.css?v=@ViewData["cssVersion"]" />
}
+11
View File
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages
{
public class WeightsModel : PageModel
{
public void OnGet()
{
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
@{
Layout = null;
ViewData["cssVersion"] = "1.0.2"; // <--- change this once to bust cache
ViewData["cssVersion"] = "1.0.8"; // <--- change this once to bust cache
}
<!DOCTYPE html>
+22
View File
@@ -12,12 +12,34 @@ builder.Services.AddControllers();
builder.Services.AddSignalR();
builder.Services.AddHttpClient("BlogApi", client =>
{
var baseUrl = configuration["BlogApi:BaseUrl"] ?? "https://media.joshheaps.net";
client.BaseAddress = new Uri(baseUrl);
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddSingleton<IBlogService, BlogService>();
builder.Services.AddSingleton<IChessService, ChessService>();
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
builder.Services.Configure<ChessEngineOptions>(configuration.GetSection(ChessEngineOptions.SectionName));
builder.Services.AddSingleton<ILearnedWeightsStore, LearnedWeightsStore>();
builder.Services.AddSingleton<IChessEngineFactory, ChessEngineFactory>();
builder.Services.AddSingleton<IComputerMoveOrchestrator, ComputerMoveOrchestrator>();
builder.Services.AddSingleton<IGameStore, GameStore>();
builder.Services.AddSingleton<ISelfPlayCoordinator, SelfPlayCoordinator>();
builder.Services.AddSingleton<AutoTrainingSettings>();
if (!builder.Environment.IsDevelopment())
{
builder.Services.AddHostedService<AutoIpUpdateService>();
// Continuously train the learned engine against Stockfish in the background. Toggle off
// via ChessEngine:AutoTrain (env ChessEngine__AutoTrain=false) without a redeploy.
if (configuration.GetValue($"{ChessEngineOptions.SectionName}:{nameof(ChessEngineOptions.AutoTrain)}", true))
builder.Services.AddHostedService<AutoTrainingService>();
}
var app = builder.Build();
// Configure the HTTP request pipeline.
@@ -11,6 +11,7 @@
"profiles": {
"http": {
"commandName": "Project",
"nativeDebugging": true,
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5200",
@@ -20,6 +21,7 @@
},
"https": {
"commandName": "Project",
"nativeDebugging": true,
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7118;http://localhost:5200",
Binary file not shown.
@@ -1,35 +1,30 @@
namespace JoshHeaps.Net.Services.Implementations;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JoshHeaps.Net.Services.Implementations;
public class AutoIpUpdateService(
IConfiguration config,
ILogger<AutoIpUpdateService> log)
: BackgroundService
{
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(5);
private static readonly HttpClient httpClient = new();
public static bool IsEnabled { get; private set; } = false;
private static readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1);
private static readonly HttpClient _httpClient = new();
protected override async Task ExecuteAsync(CancellationToken stop)
{
IsEnabled = true;
var timer = new PeriodicTimer(CheckInterval);
AAAARecord dnsRecord = await GetDnsRecordAsync();
string lastKnownIp = dnsRecord.Content;
var timer = new PeriodicTimer(_checkInterval);
while (await timer.WaitForNextTickAsync(stop))
{
try
{
string currentIp = await GetPublicIpAsync() ?? "";
if (lastKnownIp != currentIp)
{
await UpdateDnsIpAsync(config, dnsRecord, currentIp);
lastKnownIp = currentIp;
}
await UpdateIpAddressIfChanged();
}
catch (OperationCanceledException) { /* shutting down */ }
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
log.LogError(ex, "Error while attempting ip update");
@@ -37,11 +32,33 @@ public class AutoIpUpdateService(
}
}
private async Task UpdateIpAddressIfChanged()
{
var dnsRecords = await GetDnsRecordAsync();
string dnsRecordIp = dnsRecords[0].Content;
if (!dnsRecords.Records.All(x => x.Content == dnsRecords[0].Content))
dnsRecordIp = string.Empty;
string publicIp = await GetPublicIpAsync() ?? string.Empty;
if (string.IsNullOrWhiteSpace(publicIp) || dnsRecordIp == publicIp)
return;
foreach (var dnsRecord in dnsRecords.Records)
{
if (dnsRecord.Content == publicIp)
continue;
await UpdateDnsIpAsync(config, dnsRecord, publicIp);
}
}
private static async Task<string> GetPublicIpAsync()
{
try
{
return await httpClient.GetStringAsync(@"https://api.ipify.org/");
return await _httpClient.GetStringAsync(@"https://api.ipify.org/");
}
catch
{
@@ -52,7 +69,7 @@ public class AutoIpUpdateService(
}
}
private async Task<AAAARecord> GetDnsRecordAsync()
private async Task<RecordList> GetDnsRecordAsync()
{
try
{
@@ -61,8 +78,8 @@ public class AutoIpUpdateService(
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records");
Console.WriteLine(await result.Content.ReadAsStringAsync());
var records = System.Text.Json.JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
return records!.Result[0];
var records = JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
return records!;
}
catch
{
@@ -73,22 +90,23 @@ public class AutoIpUpdateService(
}
}
private static async Task UpdateDnsIpAsync(IConfiguration config, AAAARecord record, string ip)
private static async Task UpdateDnsIpAsync(IConfiguration config, DnsRecord record, string ip)
{
HttpClient cfClient = new();
cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]);
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
object content = new
object updateRequestBody = new
{
comment = "Update as needed",
name = record.Name,
ttl = record.Ttl,
type = record.Type,
comment = record.Comment,
content = ip,
name = "@",
proxied = true,
ttl = 3600,
type = "AAAA"
proxied = record.Proxied,
};
var result = await cfClient.PutAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records{record.Id}", content);
var result = await cfClient.PatchAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records/{record.Id}", updateRequestBody);
if (!result.IsSuccessStatusCode)
{
@@ -99,7 +117,23 @@ public class AutoIpUpdateService(
}
}
record AAAARecord(string Comment, string Content, string Name, string Id);
record DnsRecord(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("ttl")] int Ttl,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("comment")] string Comment,
[property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("proxied")] bool Proxied,
[property: JsonPropertyName("id")] string Id);
record RecordList(List<AAAARecord> Result);
record RecordList([property: JsonPropertyName("result")] List<DnsRecord> Records)
{
public DnsRecord this[int index]
{
get
{
return Records[index];
}
}
}
}
@@ -0,0 +1,73 @@
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Continuously trains the learned engine in the background by keeping a configurable number of
/// self-play games running — the learned engine (skill 6) against Stockfish (skill 20), alternating
/// which color Stockfish takes so the model trains on both. The target count is read live from
/// <see cref="AutoTrainingSettings"/> (adjustable from the website): when a game finishes another
/// starts to refill the pool, raising the count starts more, and lowering it lets the surplus drain
/// as games finish (0 pauses training). Registered only outside Development and gated by the
/// ChessEngine:AutoTrain config flag.
/// </summary>
public sealed class AutoTrainingService(
ISelfPlayCoordinator coordinator,
AutoTrainingSettings settings,
ILogger<AutoTrainingService> logger) : BackgroundService
{
private const int LearnedSkill = 6;
private const int StockfishSkill = 20;
private static readonly TimeSpan _restartBackoff = TimeSpan.FromSeconds(5);
private static readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var running = new List<Task>();
int started = 0;
while (!stoppingToken.IsCancellationRequested)
{
running.RemoveAll(t => t.IsCompleted);
int desired = settings.GameCount;
bool startFailed = false;
while (running.Count < desired && !stoppingToken.IsCancellationRequested)
{
// Alternate Stockfish's color so the learned engine trains as both white and black.
var config = started++ % 2 == 0
? new SelfPlayConfig(ChessEngineKind.CustomLearned, LearnedSkill, ChessEngineKind.Stockfish, StockfishSkill)
: new SelfPlayConfig(ChessEngineKind.Stockfish, StockfishSkill, ChessEngineKind.CustomLearned, LearnedSkill);
try
{
var (_, completion) = coordinator.StartGame(config, stoppingToken);
running.Add(completion);
}
catch (Exception ex)
{
// Most likely an engine failing to start (e.g. Stockfish). Back off so a
// persistent failure doesn't spin a tight loop, then try again.
logger.LogError(ex, "Failed to start an auto-training game; retrying after backoff.");
startFailed = true;
break;
}
}
try
{
if (startFailed)
await Task.Delay(_restartBackoff, stoppingToken);
else if (running.Count > 0)
// Wake when any game finishes (to refill) or after a short poll (to pick up a
// count increase promptly).
await Task.WhenAny(Task.WhenAny(running), Task.Delay(_pollInterval, stoppingToken));
else
// Pool is empty (count is 0) — just poll for the count to change.
await Task.Delay(_pollInterval, stoppingToken);
}
catch (OperationCanceledException) { break; }
}
}
}
@@ -0,0 +1,31 @@
using Microsoft.Extensions.Options;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Runtime-adjustable auto-training settings. Singleton so the value set from the website (via the
/// chess controller) is seen live by the background <see cref="AutoTrainingService"/>. Seeded from
/// <see cref="ChessEngineOptions.AutoTrainGameCount"/> and clamped to a sane range.
/// </summary>
public sealed class AutoTrainingSettings
{
/// <summary>Upper bound on concurrent auto-training games (each spawns a Stockfish + a learned engine).</summary>
public const int MaxGames = 16;
private int _gameCount;
public AutoTrainingSettings(IOptions<ChessEngineOptions> options)
=> _gameCount = Clamp(options.Value.AutoTrainGameCount);
/// <summary>
/// Number of auto-training games to keep running concurrently. 0 pauses auto-training.
/// Reads/writes are atomic; the background service reads this every cycle.
/// </summary>
public int GameCount
{
get => Volatile.Read(ref _gameCount);
set => Volatile.Write(ref _gameCount, Clamp(value));
}
private static int Clamp(int n) => Math.Clamp(n, 0, MaxGames);
}
@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using System.Text.Json;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
public class BlogService : IBlogService
{
private readonly HttpClient _httpClient;
private readonly ILogger<BlogService> _logger;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public BlogService(IHttpClientFactory httpClientFactory, ILogger<BlogService> logger)
{
_httpClient = httpClientFactory.CreateClient("BlogApi");
_logger = logger;
}
public async Task<List<BlogPost>> GetAllPostsAsync()
{
return await GetCachedAsync("all_posts",
() => FetchAsync<List<BlogPost>>("api/blog/posts")) ?? [];
}
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
{
return await GetCachedAsync($"post_{slug}",
() => FetchAsync<BlogPost>($"api/blog/posts/{Uri.EscapeDataString(slug)}"));
}
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
{
return await GetCachedAsync($"tag_{tag}",
() => FetchAsync<List<BlogPost>>($"api/blog/posts/tags/{Uri.EscapeDataString(tag)}")) ?? [];
}
private async Task<T?> FetchAsync<T>(string path) where T : class
{
try
{
var response = await _httpClient.GetAsync(path);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Blog API returned {StatusCode} for {Path}", response.StatusCode, path);
return null;
}
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch from blog API: {Path}", path);
return null;
}
}
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
{
_cache.TryGetValue(key, out var entry);
if (entry is not null && entry.ExpiresAt > DateTime.UtcNow)
return (T?)entry.Value;
var result = await factory();
if (result is not null)
{
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
return result;
}
return entry is null ? null : ServeStale<T>(key, entry);
}
// Stale entries are dropped once the grace window closes so that a dead API fails the same
// way for every key. Serving them indefinitely let a cached post list render next to 404s on
// the posts themselves, which reads as a site bug rather than an outage.
private T? ServeStale<T>(string key, CacheEntry entry) where T : class
{
if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow)
{
_logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key);
return (T?)entry.Value;
}
_cache.TryRemove(key, out _);
_logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key);
return null;
}
public void ClearCache()
{
_cache.Clear();
_logger.LogInformation("Blog cache cleared");
}
private record CacheEntry(object Value, DateTime ExpiresAt);
}
@@ -0,0 +1,64 @@
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.Extensions.Options;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>The available chess engine implementations.</summary>
public enum ChessEngineKind
{
Stockfish,
Custom,
/// <summary>The custom engine with the reinforcement-learned piece-square evaluation.</summary>
CustomLearned
}
/// <summary>Configuration selecting which <see cref="IChessEngine"/> to use.</summary>
public sealed class ChessEngineOptions
{
public const string SectionName = "ChessEngine";
public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish;
/// <summary>
/// Absolute path to the learned-weights file. Leave null to default to
/// <c>{ContentRoot}/chess-data/learned-weights.txt</c> (fine for local dev). In
/// production set this to a stable, service-writable location OUTSIDE the deploy
/// directory (e.g. <c>/var/lib/joshheaps/chess-data/learned-weights.txt</c>) so the
/// trained weights survive deploys and avoid deploy-user vs service-user permission
/// clashes. Override via the <c>ChessEngine__WeightsPath</c> environment variable.
/// </summary>
public string? WeightsPath { get; set; }
/// <summary>
/// When true (and outside Development), a background service continuously plays the learned
/// engine against Stockfish to train it. Set to false to stop auto-training without a
/// redeploy. Override via the <c>ChessEngine__AutoTrain</c> environment variable.
/// </summary>
public bool AutoTrain { get; set; } = true;
/// <summary>
/// How many auto-training games run concurrently (when <see cref="AutoTrain"/> is on). This is
/// the starting value; it can be changed at runtime from the website. Override the default via
/// the <c>ChessEngine__AutoTrainGameCount</c> environment variable.
/// </summary>
public int AutoTrainGameCount { get; set; } = 2;
}
/// <summary>Creates the configured <see cref="IChessEngine"/> per game.</summary>
public sealed class ChessEngineFactory(
IOptions<ChessEngineOptions> options,
ILearnedWeightsStore weightsStore) : IChessEngineFactory
{
private readonly ChessEngineKind _kind = options.Value.Engine;
public IChessEngine Create(int skill) => Create(skill, _kind);
public IChessEngine Create(int skill, ChessEngineKind kind) => kind switch
{
ChessEngineKind.Custom => new CustomChessEngine(skill),
ChessEngineKind.CustomLearned => new CustomChessEngine(skill, EngineVariant.Learned, weightsStore.WeightsFilePath),
ChessEngineKind.Stockfish => new Stockfish(skill),
_ => throw new InvalidOperationException($"Unknown chess engine '{kind}'.")
};
}
@@ -0,0 +1,153 @@
using JoshHeaps.Net.Models;
using System.Text;
namespace JoshHeaps.Net.Services.Implementations;
public static class ChessEngineHelpers
{
public static MoveDto ToMoveDto(
this string uci,
GameState gameState,
Guid playerId)
{
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
var piece = gameState.Board[fRow, fCol]
?? throw new Exception("No piece at source square");
PieceType? promo = uci.Length == 5 ? uci[4] switch
{
'q' => PieceType.Queen,
'r' => PieceType.Rook,
'b' => PieceType.Bishop,
'n' => PieceType.Knight,
_ => null
} : null;
return new MoveDto
{
GameId = gameState.GameId,
PlayerId = playerId,
PieceId = piece.Id,
TargetRow = tRow,
TargetCol = tCol,
PromotionChoice = promo,
SourceCol = fCol,
SourceRow = fRow,
};
}
/// <summary>
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
/// Only piece placement + active colour + castling are computed; the rest use
/// safe defaults (-, 0, 1). That is all the engine needs.
/// </summary>
public static string ToFen(this GameState gs)
{
var sb = new StringBuilder(64);
/* 1) piece placement */
for (int row = 0; row < 8; row++)
{
int empty = 0;
for (int col = 0; col < 8; col++)
{
var p = gs.Board[row, col];
if (p is null)
{
empty++;
}
else
{
if (empty > 0) { sb.Append(empty); empty = 0; }
sb.Append(ToFenChar(p)); // ← unchanged helper
}
}
if (empty > 0) sb.Append(empty);
if (row < 7) sb.Append('/');
}
/* 2) active colour */
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
/* 3) castling rights (from GameState flags) */
sb.Append(GetCastlingFlags(gs));
/* 4) en-passant target square */
sb.Append(' ');
sb.Append(gs.EnPassantTarget.HasValue
? Alg(gs.EnPassantTarget.Value)
: "-");
/* 5-6) half-move clock + full-move number */
int fullMoves = gs.MoveHistory.Count / 2 + 1;
sb.Append(' ').Append(gs.HalfmoveClock).Append(' ').Append(fullMoves);
return sb.ToString();
}
/// <summary>
/// The prior positions since the last irreversible move (capture/pawn move), as
/// completed FEN strings, oldest first and excluding the current position. This is
/// the repetition window the engine needs to detect threefold/50-move draws that a
/// single FEN can't express.
/// </summary>
public static IReadOnlyList<string> RepetitionHistory(this GameState gs)
{
int clock = gs.HalfmoveClock;
int count = gs.PositionHistory.Count;
int start = count - 1 - clock; // PositionHistory ends with the current position
if (clock <= 0 || start < 0)
return Array.Empty<string>();
var fens = new List<string>(clock);
for (int i = start; i < count - 1; i++)
fens.Add(gs.PositionHistory[i] + " 0 1"); // complete the 4-field key into a parseable FEN
return fens;
}
/* ---------- helpers ---------- */
private static string GetCastlingFlags(GameState gs)
{
var flags = new StringBuilder(4);
if (gs.WhiteCanCastleKingside) flags.Append('K');
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
if (gs.BlackCanCastleKingside) flags.Append('k');
if (gs.BlackCanCastleQueenside) flags.Append('q');
return flags.Length == 0 ? "-" : flags.ToString();
}
private static string Alg(Position p)
{
char file = (char)('a' + p.Col);
int rank = 8 - p.Row;
return $"{file}{rank}";
}
private static char ToFenChar(ChessPiece p) => p switch
{
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
_ => throw new ArgumentOutOfRangeException(nameof(p))
};
}
@@ -22,12 +22,14 @@ public class ChessService : IChessService
gameState.Pieces.Clear();
gameState.MoveHistory.Clear();
gameState.SanHistory.Clear();
gameState.WhiteCanCastleKingside = true;
gameState.WhiteCanCastleQueenside = true;
gameState.BlackCanCastleKingside = true;
gameState.BlackCanCastleQueenside = true;
gameState.EnPassantTarget = null;
gameState.HalfmoveClock = 0;
SetupBlackPieces(gameState);
SetupWhitePieces(gameState);
@@ -35,6 +37,9 @@ public class ChessService : IChessService
gameState.CurrentPlayer = PieceColor.White;
UpdateCheckStatus(gameState);
gameState.PositionHistory.Clear();
gameState.PositionHistory.Add(PositionKey(gameState));
}
private static void SetupBlackPieces(GameState gs)
@@ -110,7 +115,6 @@ public class ChessService : IChessService
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == pieceId);
if (piece == null) return [];
if (piece.Color != gameState.CurrentPlayer) return [];
var candidateMoves = GenerateCandidateMoves(gameState, piece);
var legalMoves = new List<Position>();
@@ -138,12 +142,23 @@ public class ChessService : IChessService
if (!legalMoves.Any(m => m.Row == moveDto.TargetRow && m.Col == moveDto.TargetCol))
return new MoveResultDto { Success = false, Message = "Illegal move." };
// SAN is built before the move (needs the pre-move board for captures/disambiguation);
// the check/mate suffix is appended after UpdateCheckStatus.
var sanBase = BuildSan(gameState, piece, targetPos, moveDto);
PerformMove(gameState, piece, targetPos, moveDto);
UpdateCheckStatus(gameState);
var notation = $"{piece.Id}:{piece.Position}->{targetPos}";
gameState.MoveHistory.Add(notation);
gameState.SanHistory.Add(sanBase + (gameState.IsCheckmate ? "#" : gameState.IsCheck ? "+" : ""));
var positionKey = PositionKey(gameState);
gameState.PositionHistory.Add(positionKey);
if (gameState.PositionHistory.Count(k => k == positionKey) >= 3)
gameState.IsThreefoldRepetition = true;
return new MoveResultDto
{
@@ -151,14 +166,95 @@ public class ChessService : IChessService
Message = "Move successful.",
IsCheck = gameState.IsCheck,
IsCheckmate = gameState.IsCheckmate,
IsStalemate = gameState.IsStalemate
IsStalemate = gameState.IsStalemate,
IsThreefoldRepetition = gameState.IsThreefoldRepetition
};
}
/// <summary>
/// Build the standard-algebraic notation for a move from the position BEFORE it is
/// applied (the check/mate suffix is added by the caller afterward).
/// </summary>
private string BuildSan(GameState gs, ChessPiece piece, Position target, MoveDto moveDto)
{
var from = piece.Position;
if (piece.Type == PieceType.King && Math.Abs(target.Col - from.Col) == 2)
return target.Col > from.Col ? "O-O" : "O-O-O";
bool targetOccupied = gs.Board[target.Row, target.Col] != null;
bool isEnPassant = piece.Type == PieceType.Pawn && from.Col != target.Col && !targetOccupied;
bool isCapture = targetOccupied || isEnPassant;
string dest = SquareName(target);
if (piece.Type == PieceType.Pawn)
{
var san = isCapture ? $"{FileChar(from.Col)}x{dest}" : dest;
bool promotes = (piece.Color == PieceColor.White && target.Row == 0)
|| (piece.Color == PieceColor.Black && target.Row == 7);
if (promotes)
san += "=" + PieceLetter(moveDto.PromotionChoice ?? PieceType.Queen);
return san;
}
return $"{PieceLetter(piece.Type)}{Disambiguation(gs, piece, target)}{(isCapture ? "x" : "")}{dest}";
}
/// <summary>
/// SAN disambiguation: when another piece of the same type and color can also reach the
/// target, qualify the origin by file, else rank, else both.
/// </summary>
private string Disambiguation(GameState gs, ChessPiece piece, Position target)
{
var rivals = gs.Pieces
.Where(p => p.Id != piece.Id && p.Type == piece.Type && p.Color == piece.Color && p.Position.Row >= 0)
.Where(p => GetLegalMovesForPiece(gs, p.Id).Any(m => m.Row == target.Row && m.Col == target.Col))
.ToList();
if (rivals.Count == 0)
return "";
if (rivals.All(p => p.Position.Col != piece.Position.Col))
return FileChar(piece.Position.Col).ToString();
if (rivals.All(p => p.Position.Row != piece.Position.Row))
return (8 - piece.Position.Row).ToString();
return $"{FileChar(piece.Position.Col)}{8 - piece.Position.Row}";
}
private static char FileChar(int col) => (char)('a' + col);
private static string SquareName(Position p) => $"{FileChar(p.Col)}{8 - p.Row}";
private static string PieceLetter(PieceType type) => type switch
{
PieceType.Knight => "N",
PieceType.Bishop => "B",
PieceType.Rook => "R",
PieceType.Queen => "Q",
PieceType.King => "K",
_ => ""
};
/// <summary>
/// The repetition signature of a position: the first four FEN fields — piece placement,
/// side to move, castling rights, and en-passant target. Move counters are excluded.
/// </summary>
private static string PositionKey(GameState gs)
{
var fields = gs.ToFen().Split(' ');
return string.Join(' ', fields.Take(4));
}
private static void PerformMove(GameState gs, ChessPiece piece, Position targetPos, MoveDto moveDto)
{
var oldPos = piece.Position;
var captured = gs.Board[targetPos.Row, targetPos.Col];
var isPawnMove = piece.Type == PieceType.Pawn; // captured before promotion can change Type
HandleEnPassantIfNeeded(gs, piece, targetPos, ref captured);
@@ -180,6 +276,9 @@ public class ChessService : IChessService
UpdateCastlingRights(gs, piece, oldPos);
// Reset the 50-move clock on captures and pawn moves (irreversible); else advance it.
gs.HalfmoveClock = (isPawnMove || captured != null) ? 0 : gs.HalfmoveClock + 1;
gs.CurrentPlayer = gs.CurrentPlayer == PieceColor.White
? PieceColor.Black
: PieceColor.White;
@@ -529,7 +628,14 @@ public class ChessService : IChessService
var ep = gs.EnPassantTarget.Value;
if (ep.Row == forward1 && Math.Abs(ep.Col - startCol) == 1)
moves.Add(ep);
{
// Verify there's an enemy pawn to capture
int enemyPawnRow = piece.Color == PieceColor.White ? ep.Row + 1 : ep.Row - 1;
var enemyPawn = gs.Board[enemyPawnRow, ep.Col];
if (enemyPawn != null && enemyPawn.Type == PieceType.Pawn && enemyPawn.Color != piece.Color)
moves.Add(ep);
}
}
return moves;
@@ -542,11 +648,7 @@ public class ChessService : IChessService
=> GenerateSlidingMoves(gs, piece, [(1, 1), (1, -1), (-1, 1), (-1, -1)]);
private static List<Position> GenerateQueenMoves(GameState gs, ChessPiece piece)
=> GenerateSlidingMoves(gs, piece,
[
(1, 0), (-1, 0), (0, 1), (0, -1),
(1, 1), (1, -1), (-1, 1), (-1, -1)
]);
=> [..GenerateRookMoves(gs, piece), ..GenerateBishopMoves(gs, piece)];
private static List<Position> GenerateSlidingMoves(GameState gs, ChessPiece piece, (int dr, int dc)[] directions)
{
@@ -0,0 +1,70 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Engine-agnostic glue for computer moves: get a move from the engine, apply it
/// through the rules service, and broadcast it to the game's clients.
/// </summary>
public sealed class ComputerMoveOrchestrator(
IHubContext<ChessHub> chessHub,
IChessService chessService,
ILearnedWeightsStore weightsStore) : IComputerMoveOrchestrator
{
public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state)
{
var engine = (state.CurrentPlayer == PieceColor.White ? state.WhiteComputer : state.BlackComputer)
?? throw new InvalidOperationException($"No engine is set for {state.CurrentPlayer} in game {state.GameId}.");
var uci = await engine.GetBestMoveAsync(state.ToFen(), state.RepetitionHistory());
var move = uci.ToMoveDto(state, CurrentPlayerId(state));
return await ApplyAndBroadcastAsync(state, move);
}
public async Task PlayRandomMoveAsync(GameState state)
{
var options = chessService.GetAllLegalMoves(state);
// No legal moves means the game is already over; let the caller's loop detect it.
if (options.Count == 0)
return;
var (piece, moves) = options[Random.Shared.Next(options.Count)];
var target = moves[Random.Shared.Next(moves.Count)];
var move = new MoveDto
{
GameId = state.GameId,
PlayerId = CurrentPlayerId(state),
PieceId = piece.Id,
SourceRow = piece.Position.Row,
SourceCol = piece.Position.Col,
TargetRow = target.Row,
TargetCol = target.Col,
PromotionChoice = null // a pawn cannot reach the last rank within the opening plies
};
await ApplyAndBroadcastAsync(state, move);
}
private async Task<(MoveDto move, MoveResultDto result)> ApplyAndBroadcastAsync(GameState state, MoveDto move)
{
var result = chessService.MakeMove(state, move);
// Record the played position for training (no-op for non-training games).
if (state.Trainer != nint.Zero)
weightsStore.Record(state.Trainer, state.ToFen());
await chessHub.Clients.Group(state.GameId.ToString())
.SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto());
return (move, result);
}
private static Guid CurrentPlayerId(GameState state) =>
state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId;
}
@@ -0,0 +1,162 @@
using JoshHeaps.Net.Services.Interfaces;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>Which evaluation the native engine uses.</summary>
public enum EngineVariant
{
/// <summary>The hand-crafted evaluation.</summary>
Classic,
/// <summary>Material plus a learned per-square bonus table loaded from a weights file.</summary>
Learned
}
/// <summary>
/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so).
/// Shares <see cref="IChessEngine"/> with <see cref="Stockfish"/> so the two are swappable.
/// The boundary contract is FEN string in, UCI move string out — identical to Stockfish.
/// </summary>
public sealed partial class CustomChessEngine : IChessEngine
{
private readonly EngineSafeHandle _handle;
public int Skill { get; }
public CustomChessEngine(int skill = 20, EngineVariant variant = EngineVariant.Classic, string? weightsPath = null)
{
Skill = skill;
// weights= must come last: the native side reads the path as the rest of the
// string, which lets it contain ';' and spaces.
var options = variant == EngineVariant.Learned
? $"skill={skill};variant=learned;weights={weightsPath}"
: $"skill={skill}";
var handle = NativeMethods.engine_create(options);
if (handle == IntPtr.Zero)
throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null).");
_handle = new EngineSafeHandle(handle);
}
public Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens) =>
Task.Run(() => GetBestMove(fen, string.Join('\n', historyFens)));
private unsafe string GetBestMove(string fen, string history)
{
const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL
byte* buffer = stackalloc byte[bufferLength];
var added = false;
try
{
_handle.DangerousAddRef(ref added);
var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, history, buffer, bufferLength);
if (code != 0)
throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code}).");
return Marshal.PtrToStringUTF8((IntPtr)buffer)
?? throw new InvalidOperationException("Native chess engine returned an empty move.");
}
finally
{
if (added)
_handle.DangerousRelease();
}
}
public ValueTask DisposeAsync()
{
_handle.Dispose();
return ValueTask.CompletedTask;
}
/// <summary>Guarantees the native handle is released exactly once via engine_destroy.</summary>
private sealed class EngineSafeHandle : SafeHandle
{
public EngineSafeHandle(IntPtr handle) : base(IntPtr.Zero, ownsHandle: true) => SetHandle(handle);
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle()
{
NativeMethods.engine_destroy(handle);
return true;
}
}
/// <summary>
/// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name
/// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish).
/// Internal so <see cref="LearnedWeightsStore"/> can share the single import resolver.
/// </summary>
internal static partial class NativeMethods
{
private const string LibName = "chess_engine";
static NativeMethods() =>
NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve);
private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
if (libraryName != LibName)
return IntPtr.Zero;
var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "chess_engine.dll"
: "libchess_engine.so";
var path = Path.Combine(AppContext.BaseDirectory, "Resources", fileName);
if (NativeLibrary.TryLoad(path, out var handle))
return handle;
return NativeLibrary.Load(libraryName, assembly, searchPath);
}
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial IntPtr engine_create(string? options);
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial int engine_best_move(IntPtr engine, string fen, string history, byte* outBuffer, int outLength);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void engine_destroy(IntPtr engine);
// ---- Learned-weights / training ABI (see chess_engine.h) ----
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void learned_load(string path);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial int weights_snapshot(int* outBuf, int outLen);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial IntPtr trainer_create();
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void trainer_record(IntPtr trainer, string fen);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void trainer_apply(IntPtr trainer, int winner, double weight);
[LibraryImport(LibName)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void trainer_destroy(IntPtr trainer);
}
}
@@ -0,0 +1,69 @@
using System.Collections.Concurrent;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// In-memory game registry with a delayed-removal lifecycle. Singleton: the game state is
/// process-wide, not per-request, so it lives in a service rather than static controller fields.
/// </summary>
public sealed class GameStore(ILearnedWeightsStore weightsStore) : IGameStore
{
private readonly ConcurrentDictionary<Guid, GameState> _games = [];
private readonly ConcurrentDictionary<Guid, Task> _removalTasks = [];
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _removalCts = [];
public void Add(GameState game) => _games[game.GameId] = game;
public bool TryGet(Guid id, out GameState game) => _games.TryGetValue(id, out game!);
public bool Contains(Guid id) => _games.ContainsKey(id);
public IReadOnlyCollection<GameState> All => [.. _games.Values];
public void ScheduleRemove(Guid id, TimeSpan delay)
{
if (_removalCts.TryRemove(id, out var oldCts))
{
oldCts.Cancel();
oldCts.Dispose();
}
var cts = new CancellationTokenSource();
_removalCts[id] = cts;
_removalTasks[id] = Task.Run(async () =>
{
try
{
await Task.Delay(delay, cts.Token);
if (_games.TryGetValue(id, out var game))
{
if (game.WhiteComputer is not null)
await game.WhiteComputer.DisposeAsync();
if (game.BlackComputer is not null)
await game.BlackComputer.DisposeAsync();
// Free the trainer if the game never reached ApplyLearning (e.g. timed out).
if (game.Trainer != nint.Zero)
{
weightsStore.DestroyTrainer(game.Trainer);
game.Trainer = nint.Zero;
}
}
_games.Remove(id, out _);
}
catch (OperationCanceledException) { }
finally
{
if (_removalCts.TryGetValue(id, out var currentCts) && currentCts == cts)
_removalCts.TryRemove(id, out _);
cts.Dispose();
}
});
}
}
@@ -0,0 +1,84 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.Extensions.Options;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Managed facade over the native learned-weights model (see <see cref="ILearnedWeightsStore"/>).
/// On construction it points the native engine at the weights file; everything else delegates
/// to the shared native ABI in <see cref="CustomChessEngine"/>.
/// </summary>
public sealed class LearnedWeightsStore : ILearnedWeightsStore
{
private const int Pieces = 6; // Pawn..King
private const int Squares = 64;
private const int Features = 8; // mobility N/B/R/Q, passed, isolated, doubled, king safety
public string WeightsFilePath { get; }
public LearnedWeightsStore(IHostEnvironment env, IOptions<ChessEngineOptions> options, ILogger<LearnedWeightsStore> logger)
{
// Prefer the configured path (production points this outside the deploy dir); fall
// back to the content root for local dev.
var configured = options.Value.WeightsPath;
WeightsFilePath = string.IsNullOrWhiteSpace(configured)
? Path.Combine(env.ContentRootPath, "chess-data", "learned-weights.txt")
: configured;
// A missing/unwritable/misconfigured path must not take down the whole app — the
// learned engine just plays from a neutral table and can't persist training.
try
{
Directory.CreateDirectory(Path.GetDirectoryName(WeightsFilePath)!);
CustomChessEngine.NativeMethods.learned_load(WeightsFilePath);
}
catch (Exception ex)
{
logger.LogError(ex,
"Could not initialize the learned-weights store at {Path}. The learned engine will " +
"play from a neutral table and training will not persist. In production set " +
"ChessEngine:WeightsPath (env ChessEngine__WeightsPath) to a service-writable directory.",
WeightsFilePath);
}
}
public nint CreateTrainer() => CustomChessEngine.NativeMethods.trainer_create();
public void Record(nint trainer, string fen) =>
CustomChessEngine.NativeMethods.trainer_record(trainer, fen);
public void ApplyResult(nint trainer, PieceColor winner, double weight) =>
CustomChessEngine.NativeMethods.trainer_apply(trainer, winner == PieceColor.White ? 0 : 1, weight);
public void DestroyTrainer(nint trainer) =>
CustomChessEngine.NativeMethods.trainer_destroy(trainer);
public LearnedWeightsSnapshot Snapshot()
{
const int total = Pieces * Squares * 2 + Features;
var buffer = new int[total];
unsafe
{
fixed (int* p = buffer)
CustomChessEngine.NativeMethods.weights_snapshot(p, total);
}
var mg = new int[Pieces][];
var eg = new int[Pieces][];
for (int piece = 0; piece < Pieces; piece++)
{
mg[piece] = new int[Squares];
eg[piece] = new int[Squares];
Array.Copy(buffer, piece * Squares, mg[piece], 0, Squares);
Array.Copy(buffer, Pieces * Squares + piece * Squares, eg[piece], 0, Squares);
}
var features = new int[Features];
Array.Copy(buffer, Pieces * Squares * 2, features, 0, Features);
return new LearnedWeightsSnapshot(mg, eg, features);
}
}
@@ -0,0 +1,50 @@
using JoshHeaps.Net.Models;
using System.Text;
namespace JoshHeaps.Net.Services.Implementations;
public static class PgnExporter
{
/// <summary>
/// Render a game as PGN: the standard seven-tag header plus the SAN movetext and result.
/// </summary>
public static string ToPgn(this GameState gs)
{
var result = ResultTag(gs);
var name = gs.IsComputerVsComputer ? "Computer" : null;
var sb = new StringBuilder();
sb.AppendLine("[Event \"JoshHeaps.Net Chess\"]");
sb.AppendLine("[Site \"joshheaps.net\"]");
sb.AppendLine($"[Date \"{DateTime.Now:yyyy.MM.dd}\"]");
sb.AppendLine($"[White \"{name ?? "White"}\"]");
sb.AppendLine($"[Black \"{name ?? "Black"}\"]");
sb.AppendLine($"[Result \"{result}\"]");
sb.AppendLine();
for (int i = 0; i < gs.SanHistory.Count; i++)
{
if (i % 2 == 0)
sb.Append(i / 2 + 1).Append(". ");
sb.Append(gs.SanHistory[i]).Append(' ');
}
sb.Append(result);
return sb.ToString();
}
private static string ResultTag(GameState gs)
{
if (gs.IsCheckmate)
return gs.CurrentPlayer == PieceColor.White ? "0-1" : "1-0";
if (gs.IsStalemate || gs.IsThreefoldRepetition)
return "1/2-1/2";
if (gs.IsForfeited)
return gs.Winner == PieceColor.White ? "1-0" : "0-1";
return "*";
}
}
@@ -0,0 +1,257 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
/// <summary>
/// Runs CPU-vs-CPU games: builds the game and engines, plays a randomized opening (for training
/// variety), drives the move loop to completion, then trains the learned engine from the result.
/// </summary>
public sealed class SelfPlayCoordinator(
IChessService chessService,
IChessEngineFactory engineFactory,
IComputerMoveOrchestrator orchestrator,
ILearnedWeightsStore weightsStore,
IGameStore gameStore) : ISelfPlayCoordinator
{
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
private static readonly TimeSpan _selfPlayMoveDelay = TimeSpan.FromSeconds(1);
private static readonly TimeSpan _selfPlayResultTimeout = TimeSpan.FromSeconds(30);
// Abandon a game that goes this long without a move being played — i.e. an engine (usually
// Stockfish) that crashed or froze. The game is killed with no result recorded; if it was an
// auto-training game the trainer schedules a replacement once this one's task completes.
private static readonly TimeSpan _idleTimeout = TimeSpan.FromSeconds(60);
// Plies of random legal moves at the start of a training game, so self-play and
// engine-vs-engine games explore different lines instead of replaying one game.
private const int _openingRandomPlies = 4;
public (Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default)
{
var whiteComputer = engineFactory.Create(config.WhiteSkill, config.WhiteKind);
IChessEngine blackComputer;
try
{
blackComputer = engineFactory.Create(config.BlackSkill, config.BlackKind);
}
catch
{
// Don't leak the first engine if the second fails to start (e.g. Stockfish process).
whiteComputer.DisposeAsync().AsTask().GetAwaiter().GetResult();
throw;
}
var gameState = chessService.CreateNewGame();
gameState.IsVsComputer = true;
gameState.IsComputerVsComputer = true;
gameState.WhiteJoined = true;
gameState.BlackJoined = true;
gameState.WhitePlayerId = Guid.NewGuid();
gameState.BlackPlayerId = Guid.NewGuid();
gameState.WhiteEngineKind = config.WhiteKind;
gameState.BlackEngineKind = config.BlackKind;
gameState.WhiteComputer = whiteComputer;
gameState.BlackComputer = blackComputer;
// When the learned engine is playing, attach a trainer so the outcome can train it.
if (config.WhiteKind == ChessEngineKind.CustomLearned || config.BlackKind == ChessEngineKind.CustomLearned)
gameState.Trainer = weightsStore.CreateTrainer();
gameStore.Add(gameState);
gameStore.ScheduleRemove(gameState.GameId, _computerGameTimeout);
var completion = Task.Run(() => RunAsync(gameState, cancellationToken));
return (gameState.GameId, completion);
}
/// <summary>
/// Drives the game to completion, then trains from it. Never throws — a failure (or a frozen
/// engine) just ends the game and the trainer is always freed, so callers can await or ignore.
/// </summary>
private async Task RunAsync(GameState gameState, CancellationToken cancellationToken)
{
bool aborted = false;
try
{
// Give spectators a moment to join the SignalR group before the first move.
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
// Training games open with random moves so they don't replay the same line.
if (gameState.Trainer != nint.Zero)
for (int i = 0; i < _openingRandomPlies && IsLive(gameState, cancellationToken); i++)
{
await orchestrator.PlayRandomMoveAsync(gameState);
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
}
var lastMoveCount = gameState.MoveHistory.Count;
var lastProgress = DateTime.UtcNow;
while (IsLive(gameState, cancellationToken))
{
await PlayMoveWithTimeoutAsync(gameState, cancellationToken);
// Watchdog: abandon the game if it stops making moves (a crashed or frozen engine
// can leave PlayAsync returning without progressing). Reset the clock on a real
// move; otherwise bail once nothing has happened for the idle timeout.
if (gameState.MoveHistory.Count != lastMoveCount)
{
lastMoveCount = gameState.MoveHistory.Count;
lastProgress = DateTime.UtcNow;
}
else if (DateTime.UtcNow - lastProgress > _idleTimeout)
throw new TimeoutException("no move played within the idle timeout");
await Task.Delay(_selfPlayMoveDelay, cancellationToken);
}
}
catch (OperationCanceledException) { aborted = true; /* service shutting down */ }
catch (TimeoutException)
{
aborted = true;
Console.WriteLine($"Self-play game {gameState.GameId} abandoned: no move for over " +
$"{_idleTimeout.TotalSeconds:n0}s (likely a crashed or frozen engine).");
}
catch (Exception ex)
{
aborted = true;
Console.WriteLine($"Self-play game {gameState.GameId} stopped: {ex.Message}");
}
// A clean finish trains the learned engine; an abandoned game (cancelled, crashed, or
// idle past the timeout) records nothing and just frees its trainer.
if (aborted)
DiscardTraining(gameState);
else
ApplyLearning(gameState);
// A clean finish lingers briefly so spectators see the result; an abandoned game is torn
// down immediately so its engines (and any crashed/frozen Stockfish process) are released.
if (gameStore.Contains(gameState.GameId))
gameStore.ScheduleRemove(gameState.GameId, aborted ? TimeSpan.Zero : _selfPlayResultTimeout);
}
/// <summary>
/// Plays one engine move, abandoning it if it exceeds <see cref="_idleTimeout"/> (throwing
/// <see cref="TimeoutException"/>) so a single hung move can't block the loop forever. The
/// abandoned move's eventual fault — it errors once the game's engines are disposed — is
/// observed so it isn't an unobserved task exception.
/// </summary>
private async Task PlayMoveWithTimeoutAsync(GameState gameState, CancellationToken cancellationToken)
{
var play = orchestrator.PlayAsync(gameState);
try
{
await play.WaitAsync(_idleTimeout, cancellationToken);
}
catch (TimeoutException)
{
_ = play.ContinueWith(static t => { _ = t.Exception; }, TaskScheduler.Default);
throw;
}
}
private bool IsLive(GameState gameState, CancellationToken cancellationToken) =>
!cancellationToken.IsCancellationRequested
&& gameStore.Contains(gameState.GameId)
&& !IsGameOver(gameState);
private static bool IsGameOver(GameState gameState) =>
gameState.IsCheckmate || gameState.IsStalemate || gameState.IsThreefoldRepetition || gameState.IsForfeited;
/// <summary>
/// Feeds a finished training game's result into the learned weights, then frees the trainer.
/// A checkmate is a full-strength result; a material-imbalance draw is a half-strength win
/// for the lower-material side (holding a draw while down material is a success; only drawing
/// while up is a failure). A balanced draw, forfeit, or unfinished game teaches nothing.
/// </summary>
private void ApplyLearning(GameState gameState)
{
if (gameState.Trainer == nint.Zero)
return;
if (TryDetermineOutcome(gameState, out var winner, out var weight))
weightsStore.ApplyResult(gameState.Trainer, winner, weight);
weightsStore.DestroyTrainer(gameState.Trainer);
gameState.Trainer = nint.Zero;
}
/// <summary>
/// Frees an abandoned game's trainer without recording any result — a cancelled, crashed, or
/// idle-timed-out game teaches the model nothing.
/// </summary>
private void DiscardTraining(GameState gameState)
{
if (gameState.Trainer == nint.Zero)
return;
weightsStore.DestroyTrainer(gameState.Trainer);
gameState.Trainer = nint.Zero;
}
/// <summary>
/// Determines the trainable outcome of a finished game: the winning color and the reward
/// weight. Returns false when the game teaches nothing (balanced draw, forfeit, unfinished).
/// </summary>
private static bool TryDetermineOutcome(GameState gameState, out PieceColor winner, out double weight)
{
winner = PieceColor.White;
weight = 1.0;
if (gameState.IsCheckmate)
{
// The side to move is the mated one, so the winner is the other color.
winner = gameState.CurrentPlayer == PieceColor.White ? PieceColor.Black : PieceColor.White;
return true;
}
if (gameState.IsStalemate || gameState.IsThreefoldRepetition)
{
var (white, black) = MaterialCounts(gameState);
if (white == black)
return false; // a balanced draw carries no signal
winner = white < black ? PieceColor.White : PieceColor.Black;
weight = 0.5;
return true;
}
return false; // forfeit / unfinished
}
/// <summary>Total non-king material per side (P=1, N=B=3, R=5, Q=9), for draw adjudication.</summary>
private static (int white, int black) MaterialCounts(GameState gameState)
{
int white = 0, black = 0;
for (int row = 0; row < 8; row++)
for (int col = 0; col < 8; col++)
{
var piece = gameState.Board[row, col];
if (piece is null)
continue;
int value = piece.Type switch
{
PieceType.Pawn => 1,
PieceType.Knight => 3,
PieceType.Bishop => 3,
PieceType.Rook => 5,
PieceType.Queen => 9,
_ => 0
};
if (piece.Color == PieceColor.White)
white += value;
else
black += value;
}
return (white, black);
}
}
@@ -1,22 +1,19 @@
using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.SignalR;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Channels;
namespace JoshHeaps.Net.Services.Implementations;
public sealed class Stockfish : IAsyncDisposable
public sealed class Stockfish : IChessEngine
{
private readonly Process _p;
private readonly StreamWriter _stdin;
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
private readonly int _skill;
public int Skill => _skill;
public Stockfish(int skill = 20, int hash = 256)
{
_skill = skill;
@@ -28,13 +25,13 @@ public sealed class Stockfish : IAsyncDisposable
: "stockfish-ubuntu-x86-64-sse41-popcnt"; // default: Linux
string exePath = Path.Combine(baseDir, "Resources", fileName);
if (!File.Exists(exePath))
{
throw new FileNotFoundException($"Stockfish executable not found at {exePath}. " +
"Ensure the file is present in the Resources folder of your project.");
}
Console.Write(exePath);
_p = new Process
@@ -77,8 +74,9 @@ public sealed class Stockfish : IAsyncDisposable
WaitFor("readyok").GetAwaiter().GetResult();
}
public async Task<string> GetBestMoveAsync(string fen)
public async Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens)
{
// historyFens is unused: Stockfish tracks repetition from the position it's given.
Send($"position fen {fen}");
Send($"go depth {_skill}");
string? best = null;
@@ -102,152 +100,26 @@ public sealed class Stockfish : IAsyncDisposable
if (line == token) break;
}
// If Stockfish doesn't exit on "quit" (crashed, zombie, ignores stdin close), don't hang
// here forever — that would block GameStore's removal task and leave the finished game
// stuck in the store, cluttering the watch page indefinitely.
private static readonly TimeSpan _exitTimeout = TimeSpan.FromSeconds(5);
public async ValueTask DisposeAsync()
{
Send("quit");
_stdin.Close();
await _p.WaitForExitAsync();
_p.Dispose();
}
public async Task MakeMove(GameState state, IHubContext<ChessHub> chessHub, IChessService chessService)
{
var move = await GetBestMoveAsync(state.ToFen());
var moveDto = move.ToMoveDto(
state,
state.CurrentPlayer == PieceColor.White
? state.WhitePlayerId
: state.BlackPlayerId);
var result = chessService.MakeMove(state, moveDto);
await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result);
}
}
public static class StockfishHelpers
{
public static MoveDto ToMoveDto(
this string uci,
GameState gameState,
Guid playerId)
{
int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1');
int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1');
var piece = gameState.Board[fRow, fCol]
?? throw new Exception("No piece at source square");
PieceType? promo = uci.Length == 5 ? uci[4] switch
try
{
'q' => PieceType.Queen,
'r' => PieceType.Rook,
'b' => PieceType.Bishop,
'n' => PieceType.Knight,
_ => null
} : null;
return new MoveDto
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
}
catch (TimeoutException)
{
GameId = gameState.GameId,
PlayerId = playerId,
PieceId = piece.Id,
TargetRow = tRow,
TargetCol = tCol,
PromotionChoice = promo,
SourceCol = fCol,
SourceRow = fRow,
};
}
/// <summary>
/// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string.
/// Only piece placement + active colour + castling are computed; the rest use
/// safe defaults (-, 0, 1). That is all Stockfish needs.
/// </summary>
public static string ToFen(this GameState gs)
{
var sb = new StringBuilder(64);
/* 1) piece placement */
for (int row = 0; row < 8; row++)
{
int empty = 0;
for (int col = 0; col < 8; col++)
{
var p = gs.Board[row, col];
if (p is null)
{
empty++;
}
else
{
if (empty > 0) { sb.Append(empty); empty = 0; }
sb.Append(ToFenChar(p)); // ← unchanged helper
}
}
if (empty > 0) sb.Append(empty);
if (row < 7) sb.Append('/');
if (!_p.HasExited)
_p.Kill(entireProcessTree: true);
}
/* 2) active colour */
sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b ");
/* 3) castling rights (from GameState flags) */
sb.Append(GetCastlingFlags(gs));
/* 4) en-passant target square */
sb.Append(' ');
sb.Append(gs.EnPassantTarget.HasValue
? Alg(gs.EnPassantTarget.Value)
: "-");
/* 5-6) half-move clock + full-move number */
int fullMoves = gs.MoveHistory.Count / 2 + 1;
sb.Append(" 0 ").Append(fullMoves);
return sb.ToString();
_p.Dispose();
}
/* ---------- helpers ---------- */
private static string GetCastlingFlags(GameState gs)
{
var flags = new StringBuilder(4);
if (gs.WhiteCanCastleKingside) flags.Append('K');
if (gs.WhiteCanCastleQueenside) flags.Append('Q');
if (gs.BlackCanCastleKingside) flags.Append('k');
if (gs.BlackCanCastleQueenside) flags.Append('q');
return flags.Length == 0 ? "-" : flags.ToString();
}
private static string Alg(Position p)
{
char file = (char)('a' + p.Col);
int rank = 8 - p.Row;
return $"{file}{rank}";
}
private static char ToFenChar(ChessPiece p) => p switch
{
{ Type: PieceType.Pawn, Color: PieceColor.White } => 'P',
{ Type: PieceType.Pawn, Color: PieceColor.Black } => 'p',
{ Type: PieceType.Knight, Color: PieceColor.White } => 'N',
{ Type: PieceType.Knight, Color: PieceColor.Black } => 'n',
{ Type: PieceType.Bishop, Color: PieceColor.White } => 'B',
{ Type: PieceType.Bishop, Color: PieceColor.Black } => 'b',
{ Type: PieceType.Rook, Color: PieceColor.White } => 'R',
{ Type: PieceType.Rook, Color: PieceColor.Black } => 'r',
{ Type: PieceType.Queen, Color: PieceColor.White } => 'Q',
{ Type: PieceType.Queen, Color: PieceColor.Black } => 'q',
{ Type: PieceType.King, Color: PieceColor.White } => 'K',
{ Type: PieceType.King, Color: PieceColor.Black } => 'k',
_ => throw new ArgumentOutOfRangeException(nameof(p))
};
}
}
@@ -0,0 +1,11 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
public interface IBlogService
{
Task<List<BlogPost>> GetAllPostsAsync();
Task<BlogPost?> GetPostBySlugAsync(string slug);
Task<List<BlogPost>> GetPostsByTagAsync(string tag);
void ClearCache();
}
@@ -0,0 +1,24 @@
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// An AI move-selection engine (e.g. Stockfish or the custom native engine).
/// Selects a move for a position; it does not enforce rules or broadcast updates.
/// Implementations own unmanaged resources, hence <see cref="IAsyncDisposable"/>.
/// </summary>
public interface IChessEngine : IAsyncDisposable
{
/// <summary>The engine's playing strength / search depth.</summary>
int Skill { get; }
/// <summary>
/// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q").
/// </summary>
/// <param name="fen">The current position as a FEN string.</param>
/// <param name="historyFens">
/// Prior positions since the last irreversible move, oldest first, excluding the
/// current one — lets the engine detect threefold/50-move draws the FEN can't carry.
/// May be empty.
/// </param>
/// <returns>The selected move as a UCI string.</returns>
Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens);
}
@@ -0,0 +1,27 @@
using JoshHeaps.Net.Services.Implementations;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Creates <see cref="IChessEngine"/> instances, choosing the implementation from configuration.
/// </summary>
public interface IChessEngineFactory
{
/// <summary>
/// Creates a new engine instance for a single game using the configured default engine.
/// The caller owns and disposes it.
/// </summary>
/// <param name="skill">The desired playing strength / search depth.</param>
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
IChessEngine Create(int skill);
/// <summary>
/// Creates a new engine instance for a single game using an explicitly chosen engine
/// (e.g. for picking a different engine per side in a CPU-vs-CPU game). The caller owns
/// and disposes it.
/// </summary>
/// <param name="skill">The desired playing strength / search depth.</param>
/// <param name="kind">The engine implementation to create.</param>
/// <returns>A new, owned <see cref="IChessEngine"/>.</returns>
IChessEngine Create(int skill, ChessEngineKind kind);
}
@@ -0,0 +1,26 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Drives a computer move: asks the side-to-move's engine for a move, applies it through
/// the rules service, and broadcasts the result to the game's clients.
/// </summary>
public interface IComputerMoveOrchestrator
{
/// <summary>
/// Has the side-to-move's engine pick a move for the current position, applies it, and
/// broadcasts it. The engine is taken from the game's per-side computer assignments.
/// </summary>
/// <param name="state">The game to play a move in.</param>
/// <returns>The applied move and its result.</returns>
Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state);
/// <summary>
/// Plays a uniformly-random legal move for the side to move (no engine), applying and
/// broadcasting it. Used to randomize the opening of training games so self-play and
/// engine-vs-engine games don't replay the same line every time.
/// </summary>
/// <param name="state">The game to play a random move in.</param>
Task PlayRandomMoveAsync(GameState state);
}
@@ -0,0 +1,29 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Process-wide registry of in-memory games and their cleanup lifecycle. Shared by the HTTP
/// controller (human and single-computer games) and the self-play coordinator (CPU-vs-CPU and
/// auto-training games), so every game is reachable from one place for lookup and spectating.
/// </summary>
public interface IGameStore
{
/// <summary>Add (or replace) a game in the registry.</summary>
void Add(GameState game);
/// <summary>Look a game up by id.</summary>
bool TryGet(Guid id, out GameState game);
/// <summary>Whether a game with this id is still in the registry.</summary>
bool Contains(Guid id);
/// <summary>Snapshot of all games currently in the registry.</summary>
IReadOnlyCollection<GameState> All { get; }
/// <summary>
/// Schedule removal of a game after <paramref name="delay"/>, cancelling any prior schedule
/// for it. On removal the game's engines are disposed and any training accumulator freed.
/// </summary>
void ScheduleRemove(Guid id, TimeSpan delay);
}
@@ -0,0 +1,33 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>
/// Thin managed facade over the native learned-weights model. The weights, all feature
/// computation, per-game accumulation, the update rule, and persistence live in the native
/// engine; this just points it at the weights file, hands out per-game trainers, and reads
/// the table back for visualization.
/// </summary>
public interface ILearnedWeightsStore
{
/// <summary>Absolute path to the weights file the native engine loads and saves.</summary>
string WeightsFilePath { get; }
/// <summary>A copy of the current weights (midgame/endgame tables + feature weights).</summary>
LearnedWeightsSnapshot Snapshot();
/// <summary>Creates a per-game training accumulator. The caller owns it (see <see cref="DestroyTrainer"/>).</summary>
nint CreateTrainer();
/// <summary>Records one played position (post-move FEN) into a trainer.</summary>
void Record(nint trainer, string fen);
/// <summary>
/// Applies a finished game's outcome to the global weights (and saves): rewards the
/// winner's squares/features, punishes the loser's, scaled by <paramref name="weight"/>.
/// </summary>
void ApplyResult(nint trainer, PieceColor winner, double weight);
/// <summary>Frees a trainer. Safe to call with <see cref="nint.Zero"/>.</summary>
void DestroyTrainer(nint trainer);
}
@@ -0,0 +1,23 @@
using JoshHeaps.Net.Services.Implementations;
namespace JoshHeaps.Net.Services.Interfaces;
/// <summary>Per-side engine and strength for a CPU-vs-CPU game.</summary>
public sealed record SelfPlayConfig(
ChessEngineKind WhiteKind, int WhiteSkill,
ChessEngineKind BlackKind, int BlackSkill);
/// <summary>
/// Creates and runs CPU-vs-CPU games to completion: randomized opening, move loop, and (when
/// the learned engine plays) feeding the result back into the learned weights. Used by the
/// spectator "watch" endpoint and by the auto-trainer.
/// </summary>
public interface ISelfPlayCoordinator
{
/// <summary>
/// Create, register, and start running a self-play game. Returns immediately with the new
/// game's id and a task that completes when the game finishes (or is cancelled). Callers
/// that only need the id can ignore the task; the auto-trainer awaits it to start the next.
/// </summary>
(Guid GameId, Task Completion) StartGame(SelfPlayConfig config, CancellationToken cancellationToken = default);
}
@@ -5,5 +5,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ChessEngine": {
"Engine": "Custom"
}
}
+8 -1
View File
@@ -5,5 +5,12 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"BlogApi": {
"BaseUrl": "https://media.joshheaps.net",
"InvalidateKey": "CHANGE_ME"
},
"ChessEngine": {
"Engine": "Custom"
}
}
@@ -0,0 +1,218 @@
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
margin: 0;
padding: 0;
}
.anniversary-container {
max-width: 1400px;
margin: 0 auto;
padding: 3rem 2rem;
text-align: center;
}
.anniversary-header {
color: white;
font-size: 3.5rem;
margin-bottom: 1.5rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
animation: fadeInDown 1s ease-out;
}
#WelcomeMessage {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 2rem 2.5rem;
margin: 0 auto 2rem;
max-width: 900px;
font-size: 1.3rem;
line-height: 1.8;
color: #333;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
animation: fadeIn 2s ease-out;
}
.hearts {
font-size: 2.5rem;
margin: 1.5rem 0;
animation: heartbeat 1.5s ease-in-out infinite;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
.photo-gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 2rem;
margin: 2rem auto;
max-width: 1200px;
padding: 0 1rem;
align-items: start;
}
.photo-frame {
background: white;
padding: 1rem;
border-radius: 15px;
box-shadow: 0 15px 40px rgba(0,0,0,0.3);
transition: transform 0.3s ease, box-shadow 0.3s ease;
animation: fadeInUp 1.5s ease-out;
display: flex;
flex-direction: column;
}
.photo-frame:hover {
transform: translateY(-10px);
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
}
.photo-wrapper {
width: 100%;
height: 500px;
overflow: hidden;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
}
.photo-frame img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.photo-frame:nth-child(2) img {
object-position: center 30%;
}
.photo-caption {
margin-top: 1rem;
font-style: italic;
color: #666;
font-size: 1.1rem;
padding: 0.5rem;
}
.love-message {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 2.5rem 3rem;
margin: 2rem auto;
max-width: 1000px;
color: #444;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
animation: fadeIn 2.5s ease-out;
}
.love-message h2 {
color: #764ba2;
margin-bottom: 1.5rem;
font-size: 2.5rem;
}
.love-message p {
font-size: 1.2rem;
line-height: 1.9;
margin-bottom: 1.2rem;
text-align: left;
}
.love-message p:last-of-type {
text-align: center;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes heartbeat {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@media (max-width: 900px) {
.photo-gallery {
grid-template-columns: 1fr;
max-width: 600px;
}
.photo-wrapper {
height: 450px;
}
}
@media (max-width: 768px) {
.anniversary-container {
padding: 2rem 1rem;
}
.anniversary-header {
font-size: 2.2rem;
}
#WelcomeMessage {
font-size: 1.1rem;
padding: 1.5rem;
}
.photo-wrapper {
height: 400px;
}
.love-message {
padding: 1.5rem;
}
.love-message h2 {
font-size: 1.8rem;
}
.love-message p {
font-size: 1rem;
}
.hearts {
font-size: 2rem;
}
}
+126
View File
@@ -0,0 +1,126 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
.blog-index {
max-width: 800px;
margin: 0 auto;
padding: 4rem clamp(1.5rem, 5vw, 3rem);
}
.blog-title {
color: var(--color-heading);
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 2.5rem;
}
.blog-filter {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.blog-filter strong {
color: var(--color-heading-secondary);
}
.blog-filter a {
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.blog-filter a:hover {
color: var(--color-accent-hover);
}
.blog-empty {
color: var(--color-text-subtle);
font-style: italic;
font-size: 1rem;
}
.blog-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.blog-card {
padding: 1.5rem 1.75rem;
border-left: 2px solid var(--color-accent-border);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
transition: background 0.2s;
}
.blog-card:hover {
background: var(--color-surface-hover);
}
.blog-card-link {
text-decoration: none;
}
.blog-card-title {
color: var(--color-heading-secondary);
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.3rem;
letter-spacing: -0.01em;
transition: color 0.2s;
}
.blog-card-link:hover .blog-card-title {
color: var(--color-accent);
}
.blog-card-date {
color: var(--color-text-subtle);
font-size: 0.75rem;
display: block;
margin-bottom: 0.5rem;
}
.blog-card-summary {
color: var(--color-text-muted);
margin: 0;
line-height: 1.6;
font-size: 0.875rem;
}
.blog-card-tags {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.blog-tag {
background: var(--color-surface-raised);
color: var(--color-text-muted);
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.blog-tag:hover {
background: var(--color-surface-raised-hover);
color: var(--color-heading-secondary);
}
+184
View File
@@ -0,0 +1,184 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
.blog-post {
max-width: 800px;
margin: 0 auto;
padding: 4rem clamp(1.5rem, 5vw, 3rem);
}
.blog-back {
color: var(--color-text-subtle);
text-decoration: none;
font-size: 0.85rem;
transition: color 0.2s;
}
.blog-back:hover {
color: var(--color-heading-secondary);
}
.blog-post-header {
margin: 2.5rem 0 2.5rem;
}
.blog-post-title {
color: var(--color-heading);
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 0.5rem;
line-height: 1.25;
}
.blog-post-date {
color: var(--color-text-subtle);
font-size: 0.8rem;
display: block;
}
.blog-post-tags {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.blog-tag {
background: var(--color-surface-raised);
color: var(--color-text-muted);
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.blog-tag:hover {
background: var(--color-surface-raised-hover);
color: var(--color-heading-secondary);
}
/* ── Post Content ── */
.post-content {
color: var(--color-text);
line-height: 1.8;
font-size: 1rem;
}
.post-content h1,
.post-content h2,
.post-content h3,
.post-content h4 {
color: var(--color-heading-secondary);
margin-top: 2rem;
margin-bottom: 0.75rem;
letter-spacing: -0.01em;
}
.post-content h1 { font-size: 1.6rem; }
.post-content h2 { font-size: 1.35rem; }
.post-content h3 { font-size: 1.15rem; }
.post-content p {
margin: 0 0 1.25rem;
}
.post-content a {
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.post-content a:hover {
color: var(--color-accent-hover);
}
.post-content ul,
.post-content ol {
margin: 0 0 1.25rem 1.5rem;
}
.post-content li {
margin-bottom: 0.4rem;
}
.post-content code {
background: var(--color-surface-raised);
color: var(--color-heading-secondary);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.85em;
font-family: var(--font-mono);
}
.post-content pre {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.25rem 1.5rem;
overflow-x: auto;
margin: 0 0 1.25rem;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.post-content pre::-webkit-scrollbar {
height: 6px;
}
.post-content pre::-webkit-scrollbar-track {
background: transparent;
}
.post-content pre::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
.post-content pre[class*="language-"] {
background: var(--color-surface);
}
.post-content pre code {
background: none;
color: var(--prism-text);
padding: 0;
font-size: 0.85rem;
}
.post-content blockquote {
border-left: 2px solid var(--color-accent-border);
margin: 0 0 1.25rem;
padding: 0.75rem 1.25rem;
color: var(--color-text-muted);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
}
.post-content img {
max-width: 100%;
border-radius: 8px;
margin: 1.25rem 0;
}
.post-content strong {
color: var(--color-heading-secondary);
}
.post-content hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 2rem 0;
}
@@ -0,0 +1,154 @@
/* Prism.js Tomorrow Night theme (customized) */
/* Original: https://github.com/PrismJS/prism/blob/master/themes/prism-tomorrow.css */
/* ============================================
Theme colors — edit these to restyle
============================================ */
:root {
--prism-background: #0a0a0a;
--prism-text: #909090;
--prism-comment: #555;
--prism-punctuation: #707070;
--prism-tag: #09ff00;
--prism-function: #fff;
--prism-boolean: #1ab815;
--prism-number: #1ab815;
--prism-property: #c8c8c8;
--prism-class: #c8c8c8;
--prism-keyword: #09ff00;
--prism-string: #43c760;
--prism-variable: #43c760;
--prism-operator: #999;
--prism-inserted: #09ff00;
}
code[class*="language-"],
pre[class*="language-"] {
color: var(--prism-text);
background: none;
font-family: var(--font-mono);
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
tab-size: 4;
hyphens: none;
}
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: var(--prism-background);
}
:not(pre) > code[class*="language-"] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
/* Comments */
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: var(--prism-comment);
}
/* Punctuation */
.token.punctuation {
color: var(--prism-punctuation);
}
/* Tags, attributes, deleted */
.token.tag,
.token.attr-name,
.token.namespace,
.token.deleted {
color: var(--prism-tag);
}
/* Function names */
.token.function-name,
.token.function {
color: var(--prism-function);
}
/* Booleans */
.token.boolean {
color: var(--prism-boolean);
}
/* Numbers */
.token.number {
color: var(--prism-number);
}
/* Properties */
.token.property {
color: var(--prism-property);
}
/* Classes, constants, symbols */
.token.class-name,
.token.constant,
.token.symbol {
color: var(--prism-class);
}
/* Keywords, selectors, builtins */
.token.selector,
.token.important,
.token.atrule,
.token.keyword,
.token.builtin {
color: var(--prism-keyword);
}
/* Strings, chars, attr values, regex */
.token.string,
.token.char,
.token.attr-value,
.token.regex {
color: var(--prism-string);
}
/* Variables */
.token.variable {
color: var(--prism-variable);
}
/* Operators, entities, URLs */
.token.operator,
.token.entity,
.token.url {
color: var(--prism-operator);
}
/* Inserted */
.token.inserted {
color: var(--prism-inserted);
}
/* Formatting */
.token.bold,
.token.important {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
body {
background: #1a1a1a;
min-height: 100vh;
margin: 0;
padding: 0;
}
.casino-container {
max-width: 1000px;
margin: 0 auto;
padding: 4rem 2rem;
text-align: center;
}
.casino-header {
color: #f5f5f5;
font-size: 2.5rem;
margin-bottom: 0.5rem;
font-weight: 300;
letter-spacing: 0.1em;
}
.casino-subtitle {
color: #a0a0a0;
font-size: 1rem;
margin-bottom: 4rem;
font-weight: 300;
}
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin: 3rem auto;
max-width: 900px;
padding: 0 1rem;
}
.game-card {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 2rem 1.5rem;
transition: all 0.3s ease;
cursor: pointer;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
align-items: center;
}
.game-card:hover {
background: #2f2f2f;
border-color: #4a4a4a;
transform: translateY(-2px);
}
.game-card.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.game-card.disabled:hover {
background: #2a2a2a;
border-color: #3a3a3a;
transform: none;
}
.game-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.9;
}
.game-title {
font-size: 1.2rem;
font-weight: 400;
color: #f5f5f5;
margin-bottom: 0.75rem;
letter-spacing: 0.05em;
}
.game-description {
font-size: 0.9rem;
color: #999;
line-height: 1.6;
font-weight: 300;
}
.coming-soon {
color: #666;
font-style: italic;
margin-top: 0.5rem;
font-size: 0.85rem;
}
.home-button {
margin-top: 4rem;
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
background: transparent;
color: #f5f5f5;
border: 1px solid #3a3a3a;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
letter-spacing: 0.05em;
}
.home-button:hover {
background: #2a2a2a;
border-color: #4a4a4a;
}
@media (max-width: 768px) {
.casino-header {
font-size: 2rem;
}
.games-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,541 @@
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
body {
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
min-height: 100vh;
margin: 0;
color: #e6e6e6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* Same touch handling as the dealer table — see the note there. This page is the one
a dealer opens mid-hand to chop a pot, so the ~300ms the browser spends deciding
whether a tap was a double-tap-to-zoom is the last thing it needs. */
button,
[role="button"],
input,
select,
textarea,
a {
touch-action: manipulation;
}
button,
[role="button"],
input,
select,
textarea,
a {
-webkit-tap-highlight-color: transparent;
}
/* Not on the fields — a dealer typing a pot amount still needs to select it. */
button,
[role="button"] {
-webkit-user-select: none;
user-select: none;
}
.clearPotBtn:active:not(:disabled),
.choiceChip:active:not(:disabled),
.stepBtn:active:not(:disabled),
.splitBtn:active:not(:disabled),
.splitBtnGhost:active:not(:disabled),
.unevenFoldBtn:active:not(:disabled),
.unevenRemoveBtn:active:not(:disabled) {
transform: translateY(1px);
filter: brightness(1.25);
transition-duration: 0.04s;
}
@media (prefers-reduced-motion: reduce) {
.clearPotBtn:active:not(:disabled),
.choiceChip:active:not(:disabled),
.stepBtn:active:not(:disabled),
.splitBtn:active:not(:disabled),
.splitBtnGhost:active:not(:disabled),
.unevenFoldBtn:active:not(:disabled),
.unevenRemoveBtn:active:not(:disabled) {
transform: none;
}
}
#splitApp {
max-width: 480px;
margin: 0 auto;
padding: 1.25rem 1rem 3rem;
}
/* ---------- Header ---------- */
#splitHeader h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 300;
letter-spacing: 0.04em;
color: #f5f5f5;
position: relative;
display: inline-block;
padding-bottom: 0.35rem;
}
#splitHeader h1::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 36px;
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, #d8b46a, #a9782f);
}
.splitSubtitle {
margin: 0.35rem 0 1.25rem;
color: #8f8f8f;
font-size: 0.85rem;
}
/* ---------- Mode toggle ---------- */
#modeToggle {
margin-bottom: 1.1rem;
}
/* ---------- Uneven amounts ---------- */
#unevenRows {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 0.6rem;
}
.unevenRow {
display: flex;
align-items: center;
gap: 0.45rem;
}
.unevenRowLabel {
flex: none;
width: 2rem;
color: #8f8f8f;
font-size: 0.8rem;
font-weight: 600;
}
.unevenAmountWrap {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.3rem;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 10px;
padding: 0 0.6rem;
}
.unevenAmountWrap:focus-within {
border-color: #d8b46a;
}
.unevenCurrency {
color: #777;
}
.unevenAmount {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: #f2f2f2;
font-family: inherit;
font-size: 16px;
font-variant-numeric: tabular-nums;
padding: 0.6rem 0;
}
.unevenFoldBtn {
flex: none;
min-height: 44px;
padding: 0 0.6rem;
border-radius: 9px;
border: 1px solid #3a3a3a;
background: #232323;
color: #8f8f8f;
font-family: inherit;
font-size: 0.72rem;
cursor: pointer;
}
.unevenFoldBtn.selected {
background: linear-gradient(180deg, #3d1f1f, #2c1515);
border-color: #5b2626;
color: #ffd6d6;
}
.unevenRemoveBtn {
flex: none;
width: 30px;
height: 44px;
border: none;
background: none;
color: #6a6a6a;
font-size: 0.85rem;
cursor: pointer;
}
.unevenRemoveBtn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
#unevenResult {
background: linear-gradient(160deg, #1f2a21, #161c17);
border: 1px solid #2c3a2f;
border-radius: 14px;
padding: 0.9rem 1rem;
margin-bottom: 1.5rem;
}
#unevenResult[hidden] {
display: none;
}
.unevenPot {
display: flex;
align-items: baseline;
gap: 0.6rem;
padding: 0.3rem 0;
font-size: 0.85rem;
}
.unevenPot + .unevenPot {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.unevenPotLabel {
flex: none;
color: #d8b46a;
font-weight: 600;
}
.unevenPotAmount {
flex: none;
color: #f2f2f2;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.unevenPotWho {
flex: 1;
min-width: 0;
text-align: right;
color: #9a9a9a;
font-size: 0.78rem;
}
.unevenPotReturn .unevenPotLabel,
.unevenPotReturn .unevenPotAmount {
color: #9a9a9a;
font-weight: 500;
}
.unevenPotTotal {
margin-top: 0.15rem;
border-top: 1px solid rgba(255, 255, 255, 0.14) !important;
}
.unevenPotTotal .unevenPotLabel {
color: #8f9d92;
font-weight: 500;
}
.unevenEmpty {
margin: 0;
color: #8f9d92;
font-size: 0.82rem;
line-height: 1.45;
}
/* ---------- The answer ---------- */
#splitResult {
background: linear-gradient(160deg, #1f2a21, #161c17);
border: 1px solid #2c3a2f;
border-radius: 14px;
padding: 1.4rem 1.1rem;
text-align: center;
margin-bottom: 1.5rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
#splitResult.hasAmount {
border-color: #3c6b46;
box-shadow: 0 0 0 1px rgba(78, 201, 122, 0.08), 0 6px 20px rgba(0, 0, 0, 0.35);
}
#splitResultLabel {
color: #8f9d92;
font-size: 0.78rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
#splitResultAmount {
margin-top: 0.35rem;
font-size: clamp(2.6rem, 14vw, 3.4rem);
font-weight: 600;
line-height: 1.05;
color: #f2f2f2;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
#splitResult.hasAmount #splitResultAmount {
color: #7ee2a0;
}
#splitOddNote {
margin-top: 0.7rem;
color: #d8b46a;
font-size: 0.85rem;
line-height: 1.45;
}
#splitDustNote {
margin-top: 0.5rem;
color: #e0a1a1;
font-size: 0.8rem;
line-height: 1.4;
}
/* ---------- Inputs ---------- */
.splitField {
display: block;
margin-bottom: 1.35rem;
}
/* A `display` declaration outranks the browser's own [hidden] rule, so anything
given one has to opt back out explicitly or toggling `hidden` does nothing. */
[hidden] {
display: none !important;
}
.splitLabel {
display: block;
margin-bottom: 0.5rem;
color: #9a9a9a;
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.potInputWrap {
display: flex;
align-items: center;
gap: 0.4rem;
background: #141414;
border: 1px solid #3a3a3a;
border-radius: 12px;
padding: 0.2rem 0.75rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.potInputWrap:focus-within {
border-color: #d8b46a;
box-shadow: 0 0 0 3px rgba(216, 180, 106, 0.12);
}
.potCurrency {
color: #777;
font-size: 1.6rem;
font-weight: 300;
}
#potInput {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: #f2f2f2;
font-size: 2rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
padding: 0.55rem 0;
font-family: inherit;
}
#potInput::placeholder {
color: #4a4a4a;
}
.clearPotBtn {
flex: none;
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: #262626;
color: #999;
font-size: 0.85rem;
cursor: pointer;
}
@media (hover: hover) {
.clearPotBtn:hover {
background: #333;
color: #ddd;
}
}
/* ---------- Tap targets ---------- */
.chipRow {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.choiceChip {
flex: 1 1 auto;
min-width: 58px;
min-height: 52px;
padding: 0.5rem 0.7rem;
background: linear-gradient(180deg, #2e2e2e, #232323);
border: 1px solid #3a3a3a;
border-radius: 11px;
color: #ddd;
font-size: 1rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
@media (hover: hover) {
.choiceChip:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
}
.choiceChip.selected {
background: linear-gradient(180deg, #3a2f18, #241d0f);
border-color: #d8b46a;
color: #f0d9a8;
font-weight: 600;
}
.waysStepper {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 1 1 auto;
min-width: 150px;
background: #191919;
border: 1px solid #333;
border-radius: 11px;
padding: 0.2rem 0.4rem;
}
.stepBtn {
width: 40px;
height: 44px;
border: none;
border-radius: 8px;
background: #262626;
color: #ddd;
font-size: 1.25rem;
line-height: 1;
font-family: inherit;
cursor: pointer;
}
@media (hover: hover) {
.stepBtn:hover {
background: #333;
}
}
.waysReadout {
flex: 1;
text-align: center;
color: #999;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
/* ---------- Footer actions ---------- */
#splitActions {
display: flex;
gap: 0.6rem;
margin-top: 1.75rem;
}
.splitBtn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 0.6rem 0.9rem;
border-radius: 10px;
border: 1px solid #3a3a3a;
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #ddd;
font-size: 0.9rem;
font-family: inherit;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
}
@media (hover: hover) {
.splitBtn:hover {
border-color: #4a4a4a;
background: linear-gradient(180deg, #383838, #2a2a2a);
}
}
.splitBtnGhost {
background: transparent;
}
.splitFootnote {
margin-top: 1.5rem;
color: #6f6f6f;
font-size: 0.78rem;
line-height: 1.5;
text-align: center;
}
/* This footnote names a control the dealer is being told to reach for, so the name
itself has to be readable — it was 3.39:1 at its worst, inheriting the muted grey
the surrounding sentence is deliberately set in. Emphasised text carrying the
instruction gets its own colour rather than the aside's. */
.splitFootnote strong {
color: #a8a8a8;
font-weight: 600;
}
.splitFootnote a {
color: #a9782f;
}
/* Roomier once there's a real screen to work with. */
@media (min-width: 700px) {
#splitApp {
padding-top: 2.5rem;
}
#splitHeader h1 {
font-size: 1.8rem;
}
}
+67 -3
View File
@@ -1,7 +1,4 @@
#chessBoard {
margin: 2vw auto;
width: 60vw;
height: 60vw;
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
@@ -158,4 +155,71 @@
width: 50px;
height: 50px;
pointer-events: none; /* ensures img doesn't steal the click */
}
#colorModal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #1e1e1e;
padding: 20px;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0,0,0,0.6);
color: white;
z-index: 1000;
text-align: center;
}
#colorModal p {
margin-bottom: 15px;
font-size: 18px;
font-weight: bold;
}
#colorButtonContainer {
display: flex;
gap: 10px;
justify-content: center;
}
#colorButtonContainer button {
background-color: #2c2c2c;
border: none;
padding: 10px 18px;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s ease;
color: white;
}
#colorButtonContainer button:hover {
transform: scale(1.1);
background-color: #3a3a3a;
}
.chessSquare .coordinate-label {
position: absolute;
font-size: 1.2vw;
font-weight: bold;
pointer-events: none;
user-select: none;
}
.chessSquare.light .coordinate-label {
color: #656770;
}
.chessSquare.dark .coordinate-label {
color: #ccc;
}
.chessSquare .row-label {
top: 2px;
left: 4px;
}
.chessSquare .col-label {
bottom: 2px;
right: 4px;
}
+396 -104
View File
@@ -1,137 +1,429 @@
html, body {
background-color: #2b2c30;
color: #d6d6d6;
cursor: default;
:root {
--bg: #2b2c30;
--panel: #21232a;
--panel-row: rgba(255, 255, 255, 0.035);
--bar: #1e2026;
--accent: #8cd5ed;
--accent-ink: #10222a;
--text: #d6d6d6;
--muted: #8b8f99;
--line: #34373f;
--bar-h: clamp(34px, 5.5vh, 50px);
--radius: 12px;
--font: 'Outfit', system-ui, -apple-system, Segoe UI, sans-serif;
}
html, body {
height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
cursor: default;
/* The chess page is a fixed, single-screen app: never scroll. */
overflow: hidden;
}
#startGameBtn {
background-color: #8cd5ed;
color: #262626;
border-radius: 30px;
border: 0px;
cursor: pointer;
order: 1;
padding: 2vh;
margin: 2vh;
main {
height: 100%;
}
#startCPUGame {
background-color: #8cd5ed;
color: #262626;
border-radius: 30px;
border: 0px;
cursor: pointer;
order: 1;
padding: 2vh;
margin: 2vh;
}
/* ---- Layout ------------------------------------------------------------ */
/* Portrait / narrow: board on top, panel stacked below. */
#chessContainer {
height: 100vh;
height: 100dvh;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-evenly;
height: 100vh;
box-sizing: border-box;
padding: 5vw;
gap: clamp(8px, 1.6vh, 16px);
padding: clamp(10px, 2vh, 20px);
overflow: hidden;
}
#boardArea {
display: flex;
flex-direction: column;
gap: 6px;
flex: 0 0 auto;
width: min(94vw, 56vh);
}
#chessBoard {
aspect-ratio: 1 / 1;
width: 90vw; /* use the smaller of width or height */
width: 100%;
height: auto;
max-height: 90vw;
aspect-ratio: 1 / 1;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
#boardContainer {
align-content: center;
flex-grow: 1;
}
.sideContent {
#gamePanel {
width: min(94vw, 56vh);
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
text-align: center;
background: var(--panel);
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
}
#textContainer {
flex-direction: column;
order: -1;
flex-shrink: 1;
font-size: large;
margin: 5vw;
}
#buttonContainer {
flex-grow: 3;
}
/* Put UI to left/right when screen is short */
/* Landscape / wide: board left, game panel right (chess.com style). */
@media (min-aspect-ratio: 1/1) {
#chessContainer {
display: grid;
grid-template-columns: auto, auto;
grid-template-rows: auto, auto;
align-items: center;
flex-direction: row;
align-items: center;
justify-content: center;
align-items: center;
height: 100vh;
padding: 0;
gap: clamp(16px, 3vw, 48px);
padding: clamp(12px, 3vh, 28px);
}
.sideContent {
display: flex;
flex-direction: column;
flex-grow: 1;
flex-shrink: 0;
align-items: center;
text-align: center;
padding: min(5vw, 5vh);
}
#boardContainer {
grid-row: 1 / span 2;
grid-column: 1;
}
#textContainer {
grid-row: 1;
grid-column: 2;
text-align: center;
font-size: x-large;
}
#buttonContainer {
grid-row: 2;
grid-column: 2;
text-align: left;
align-self: start;
}
#startGameBtn {
padding: 1vw;
font-size: large;
}
#startCPUGame {
padding: 1vw;
margin: 1vw;
font-size: large;
#boardArea {
width: auto;
height: 100%;
justify-content: center;
flex: 0 0 auto;
}
#chessBoard {
aspect-ratio: 1 / 1;
flex-shrink: 1;
width: min(90vh, 90vw);
max-height: 90vh;
width: min(72vh, 54vw);
height: min(72vh, 54vw);
}
#difficultyButtonContainer {
grid-template-columns: repeat(10, 1fr);
.playerBar {
width: min(72vh, 54vw);
}
}
#gamePanel {
width: clamp(300px, 26vw, 380px);
height: min(86vh, 100%);
align-self: center;
}
}
/* ---- Player bars ------------------------------------------------------- */
.playerBar {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
box-sizing: border-box;
height: var(--bar-h);
padding: 0 12px;
background: var(--bar);
border-radius: 8px;
}
.playerDot {
width: 14px;
height: 14px;
border-radius: 50%;
flex: 0 0 auto;
}
.playerDot.white {
background: #ededed;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4) inset;
}
.playerDot.black {
background: #2c2c2c;
box-shadow: 0 0 0 1px #565656 inset;
}
.playerName {
font-weight: 600;
white-space: nowrap;
}
.capturedTray {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.capturedPiece {
height: clamp(15px, calc(var(--bar-h) * 0.58), 26px);
width: auto;
margin-right: -5px;
}
.advantage {
margin-left: auto;
font-weight: 700;
color: var(--accent);
white-space: nowrap;
}
/* ---- Game panel -------------------------------------------------------- */
.panelHeader {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
flex: 0 0 auto;
}
.panelLogo {
font-size: 1.5rem;
color: var(--accent);
line-height: 1;
}
.panelHeader h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.01em;
}
#moveList {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 6px 0;
}
.movePlaceholder {
margin: 0;
padding: 18px 16px;
color: var(--muted);
font-size: 0.95rem;
}
.moveRow {
display: grid;
grid-template-columns: 2.4em 1fr 1fr;
align-items: center;
gap: 6px;
padding: 4px 14px;
font-size: 0.98rem;
}
.moveRow:nth-child(odd) {
background: var(--panel-row);
}
.moveNum {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.moveSan {
padding: 2px 7px;
border-radius: 5px;
}
.moveSan.latest {
background: var(--accent);
color: var(--accent-ink);
font-weight: 600;
}
#statusLine {
flex: 0 0 auto;
padding: 12px 16px;
border-top: 1px solid var(--line);
color: var(--muted);
font-weight: 500;
}
#statusLine.alert {
color: var(--accent);
font-weight: 700;
}
#panelButtons {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px 16px;
border-top: 1px solid var(--line);
}
.btn {
font-family: inherit;
font-weight: 600;
font-size: clamp(0.92rem, 1vw, 1.05rem);
border: 0;
border-radius: 8px;
padding: clamp(10px, 1.4vh, 14px) 16px;
cursor: pointer;
transition: transform 0.08s ease, filter 0.15s ease, background 0.15s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: var(--accent);
color: var(--accent-ink);
}
.btn-primary:hover {
filter: brightness(1.08);
}
.btn-secondary {
background: #363a44;
color: var(--text);
}
.btn-secondary:hover {
background: #424752;
}
.btn-ghost {
background: transparent;
color: var(--accent);
border: 1px solid var(--line);
}
.btn-ghost:hover {
background: #2a2e37;
}
#watchLink {
color: var(--muted);
text-decoration: none;
text-align: center;
font-size: 0.9rem;
margin-top: 2px;
}
#watchLink:hover {
color: var(--accent);
}
/* ---- Mobile chrome (hidden on desktop) --------------------------------- */
.iconBtn {
background: transparent;
border: 0;
color: var(--text);
font-size: 1.7rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.iconBtn:hover {
color: var(--accent);
}
#menuClose {
margin-left: auto;
}
/* Default (tablet/desktop): no mobile bar, menu button, or sheet chrome. */
#mobileBar,
#menuBackdrop,
#menuClose {
display: none;
}
/* ---- Phone layout ------------------------------------------------------ */
/* The full panel doesn't fit alongside a usable board on a phone, so we show
the board + a slim status/menu bar, and tuck the move list and secondary
actions into a slide-up sheet. The page itself still never scrolls. */
@media (max-width: 640px) {
#chessContainer {
flex-direction: column;
justify-content: flex-start;
gap: 8px;
padding: 8px;
}
#boardArea {
width: min(94vw, 60vh);
flex: 0 0 auto;
}
.playerBar {
width: 100%;
}
/* Right rail becomes a hidden bottom sheet, revealed by the Menu button. */
#gamePanel {
display: none;
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: auto;
width: 100%;
height: auto;
max-height: 85dvh;
border-radius: 16px 16px 0 0;
z-index: 60;
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5);
}
body.menu-open #gamePanel {
display: flex;
}
/* The slim bar already shows status, so hide the sheet's inline copy. */
#gamePanel #statusLine {
display: none;
}
#menuClose {
display: block;
}
#menuBackdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 55;
}
body.menu-open #menuBackdrop {
display: block;
}
#mobileBar {
display: flex;
align-items: center;
gap: 10px;
width: min(94vw, 60vh);
flex: 0 0 auto;
box-sizing: border-box;
background: var(--panel);
border-radius: 10px;
padding: 8px 10px;
}
#mobileStatus {
flex: 1;
min-width: 0;
color: var(--muted);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#mobileStatus.alert {
color: var(--accent);
}
#mobileBar #menuToggle {
flex: 0 0 auto;
padding: 10px 20px;
}
}
@@ -0,0 +1,165 @@
html, body {
background-color: #2b2c30;
color: #d6d6d6;
margin: 0;
}
#watchHeader {
text-align: center;
padding: 2rem 1rem 0;
}
#watchHeader h1 {
margin: 0;
}
#watchStatus {
color: #9a9a9a;
}
#watchControls {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1rem 0;
}
.enginePicker {
display: flex;
align-items: center;
gap: 0.4rem;
border: 1px solid #444;
border-radius: 8px;
padding: 0.3rem 0.6rem 0.5rem;
}
.enginePicker legend {
color: #9a9a9a;
font-size: 0.8rem;
padding: 0 0.3rem;
}
#watchControls select {
background-color: #1e1e1e;
color: #d6d6d6;
border: 1px solid #444;
border-radius: 6px;
padding: 0.4rem;
}
#startCpuVsCpu {
background-color: #8cd5ed;
color: #262626;
border: 0;
border-radius: 30px;
padding: 0.6rem 1.2rem;
cursor: pointer;
}
#startCpuVsCpu:hover {
background-color: #a5e0f2;
}
#backToPlay {
color: #8cd5ed;
text-decoration: none;
}
#backToPlay:hover {
text-decoration: underline;
}
#gamesFeed {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
padding: 2rem;
box-sizing: border-box;
}
.gameCard {
background-color: #1e1e1e;
border-radius: 10px;
padding: 1rem;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
position: relative;
cursor: pointer;
}
.gameResult {
text-align: center;
font-weight: bold;
color: #8cd5ed;
margin-top: 0.75rem;
}
.copyPgnBtn {
display: block;
margin: 0.75rem auto 0;
background-color: #8cd5ed;
color: #262626;
border: 0;
border-radius: 30px;
padding: 0.5rem 1rem;
cursor: pointer;
}
.copyPgnBtn:hover {
background-color: #a5e0f2;
}
.backButton {
display: none;
}
body.fullscreen-open {
overflow: hidden;
}
.gameCard.fullscreen {
position: fixed;
inset: 0;
z-index: 1000;
margin: 0;
border-radius: 0;
cursor: default;
background-color: #2b2c30;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.gameCard.fullscreen .miniBoard {
width: min(90vh, 90vw);
height: min(90vh, 90vw);
}
.gameCard.fullscreen .backButton {
display: inline-block;
position: absolute;
top: 1rem;
left: 1rem;
background-color: #8cd5ed;
color: #262626;
border: 0;
border-radius: 30px;
padding: 0.6rem 1.2rem;
cursor: pointer;
}
.gameCardHeader {
text-align: center;
font-weight: bold;
margin-bottom: 0.75rem;
}
.miniBoard {
width: 100%;
aspect-ratio: 1 / 1;
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
}
+187
View File
@@ -0,0 +1,187 @@
body {
margin: 0;
background-color: #141414;
color: #d6d6d6;
font-family: "Segoe UI", system-ui, sans-serif;
}
#weightsHeader {
text-align: center;
padding: 1.5rem 1rem 0.5rem;
}
#weightsHeader h1 {
margin: 0 0 0.25rem;
font-size: 1.6rem;
}
#weightsStatus {
color: #9a9a9a;
margin: 0.25rem 0 1rem;
}
#weightsControls {
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
margin-bottom: 0.5rem;
}
.heatLegend {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #9a9a9a;
}
.legendBar {
width: 120px;
height: 10px;
border-radius: 5px;
background: linear-gradient(to right, rgba(232, 74, 74, 1), #2a2a2a, rgba(74, 134, 232, 1));
}
#refreshWeights {
background-color: #8cd5ed;
color: #262626;
border: 0;
border-radius: 30px;
padding: 0.5rem 1.1rem;
cursor: pointer;
}
#refreshWeights:hover {
background-color: #a5e0f2;
}
#backToWatch {
display: inline-block;
margin-top: 0.5rem;
color: #8cd5ed;
text-decoration: none;
}
.weightsSection {
margin: 0 auto;
max-width: 1200px;
padding: 0.5rem 1rem;
}
.weightsSection h2 {
text-align: center;
font-size: 1.2rem;
margin: 1rem 0 0.25rem;
}
.sectionHint {
text-align: center;
color: #9a9a9a;
font-size: 0.8rem;
margin: 0 0 0.75rem;
}
.weightsGrid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1.25rem;
padding: 0.5rem 0;
}
.featurePanel {
max-width: 520px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.featureRow {
display: flex;
align-items: center;
gap: 0.6rem;
}
.featureLabel {
flex: 0 0 6.5rem;
font-size: 0.8rem;
color: #c8c8c8;
text-align: right;
}
.featureTrack {
flex: 1 1 auto;
height: 14px;
background-color: #232323;
border-radius: 7px;
overflow: hidden;
}
.featureBar {
height: 100%;
border-radius: 7px;
min-width: 2px;
}
.featureValue {
flex: 0 0 3rem;
font-size: 0.8rem;
color: #e8e8e8;
text-align: left;
}
.weightBoard {
background-color: #1c1c1c;
border: 1px solid #2e2e2e;
border-radius: 10px;
padding: 0.75rem;
}
.weightBoardHeader {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.weightBoardIcon {
width: 22px;
height: 22px;
}
.weightRange {
margin-left: auto;
font-weight: 400;
font-size: 0.75rem;
color: #888;
}
.miniHeat {
display: grid;
grid-template-columns: 1.1rem repeat(8, 34px);
}
.heatSquare {
width: 34px;
height: 34px;
box-sizing: border-box;
border: 1px solid #2a2a2a;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
color: #f0f0f0;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.heatLabel {
display: flex;
align-items: center;
justify-content: center;
padding: 2px 0;
font-size: 0.6rem;
color: #777;
}
+65
View File
@@ -0,0 +1,65 @@
/* ── Blog Section ── */
#BlogBox {
max-width: 1100px;
margin: clamp(4rem, 8vw, 8rem) auto;
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
#blogPostsList {
display: flex;
flex-direction: column;
gap: 1rem;
}
.blog-teaser-item {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 1.25rem 1.5rem;
border-left: 2px solid var(--color-accent-border);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
transition: background 0.2s;
}
.blog-teaser-item:hover {
background: var(--color-surface-hover);
}
.blog-teaser-title {
font-size: clamp(1rem, 1.5vw, 1.15rem);
font-weight: 600;
color: var(--color-heading-secondary);
text-decoration: none;
transition: color 0.2s;
}
.blog-teaser-title:hover {
color: var(--color-accent);
}
.blog-teaser-date {
font-size: 0.75rem;
color: var(--color-text-subtle);
}
.blog-teaser-summary {
font-size: 0.85rem;
color: var(--color-text-muted);
margin: 0.25rem 0 0;
line-height: 1.6;
}
.blog-view-all {
display: inline-block;
margin-top: 1.5rem;
font-size: 0.85rem;
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.blog-view-all:hover {
color: var(--color-accent-hover);
}
+19 -11
View File
@@ -1,17 +1,25 @@
#Contacts {
margin-top: 20vw;
/* ── Footer / Contacts ── */
#Contacts {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: clamp(4rem, 8vw, 6rem) 0 clamp(3rem, 5vw, 4rem);
}
.social-link {
display: flex;
align-items: center;
text-decoration: none;
}
.social-icon {
height: auto;
width: 3.5vw;
margin-bottom: 5vw;
margin-left: 3vw;
fill: rgb(212, 212, 212);
transition: fill 0.5s;
width: clamp(1.25rem, 2.5vw, 1.75rem);
fill: var(--color-text-subtle);
transition: fill 0.2s;
}
.social-icon:hover {
fill: white;
transition: fill 0.5s;
}
.social-link:hover .social-icon {
fill: var(--color-heading-secondary);
}
@@ -1,36 +0,0 @@
.diagonal-section-left {
position: absolute;
clip-path: polygon(0 0, 85vw 0, 60vw 100%, 0 100%);
background-color: rgb(104, 255, 0, 0.39);
transition: clip-path 0.5s ease;
}
.diagonal-section-left:has(.projectImage:hover) {
clip-path: polygon(0 0, 87vw 0, 58vw 100%, 0 100%);
transition: clip-path 0.5s ease;
}
.projectImageLeft {
width: 33vw;
height: auto;
margin: 3vw;
}
.projectTextRight {
position: relative;
z-index: 1;
margin-left: 50vw;
}
.projectHeaderRight {
margin: 0;
color: rgb(212, 212, 212);
font-size: 4vw;
}
.projectDescriptionRight {
margin: 0;
color: rgb(212, 212, 212);
font-size: 2vw;
width: 40vw;
}
@@ -1,38 +0,0 @@
.diagonal-section-right {
position: absolute;
clip-path: polygon(40vw 0, 100% 0, 100% 100%, 15vw 100%);
background-color: rgb(104, 255, 0, 0.39);
transition: clip-path 0.5s ease;
}
.diagonal-section-right:has(.projectImage:hover) {
clip-path: polygon(42vw 0, 100% 0, 100% 100%, 13vw 100%);
transition: clip-path 0.5s ease;
}
.projectImageRight {
width: 33vw;
height: auto;
margin: 3vw;
float: right;
}
.projectTextLeft {
position: relative;
z-index: 1;
}
.projectHeaderLeft {
margin: 0;
color: rgb(212, 212, 212);
font-size: 4vw;
margin-left: 10vw;
}
.projectDescriptionLeft {
margin: 0;
color: rgb(212, 212, 212);
font-size: 2vw;
width: 40vw;
margin-left: 10vw;
}
+78 -17
View File
@@ -1,29 +1,90 @@
#ProjectsBox {
/* ── Projects Section ── */
#ProjectsBox {
max-width: 1100px;
margin: 0 auto;
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
.projects-grid {
display: grid;
grid-auto-rows: 1fr;
gap: 20vw;
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 1.5rem;
}
.projectContainer {
position: relative;
display: flex;
align-items: center;
/* ── Card ── */
.project-card {
border: 1px solid var(--color-border);
border-radius: 12px;
overflow: hidden;
background: var(--color-surface);
transition: border-color 0.25s, transform 0.25s;
}
.displayBox {
width: 100vw;
height: auto;
.project-card:hover {
border-color: var(--color-border-hover);
transform: translateY(-2px);
}
a.projectContainer {
text-decoration: none;
/* ── Image ── */
.project-image-link {
display: block;
overflow: hidden;
}
.projectImage {
transition: width 0.5s ease;
width: 100%;
height: auto;
display: block;
transition: transform 0.4s ease;
}
.projectImage:hover {
width: 40vw;
transition: width 0.5s ease;
}
.project-card:hover .projectImage {
transform: scale(1.03);
}
/* ── Info ── */
.project-info {
padding: 1.25rem 1.5rem 1.5rem;
}
.project-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-heading);
letter-spacing: -0.01em;
}
.project-description {
margin: 0 0 1rem;
font-size: 0.85rem;
line-height: 1.65;
color: var(--color-text-muted);
}
.project-link {
font-size: 0.8rem;
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.project-link:hover {
color: var(--color-accent-hover);
}
/* ── Mobile ── */
@media (max-width: 600px) {
.projects-grid {
grid-template-columns: 1fr;
}
.project-info {
padding: 1rem 1.25rem 1.25rem;
}
}
+109 -42
View File
@@ -1,67 +1,134 @@
body {
margin: 0px;
background-color: #2b2c30;
color: #09ff00;
overflow-x: hidden;
cursor: default;
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
/* ── Navigation ── */
#Header {
margin-top: 2vw;
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1100px;
margin: 0 auto;
padding: 2rem clamp(1.5rem, 5vw, 4rem);
}
.headerOption {
font: bold 2vw arial;
display: inline;
margin-left: 10vw;
.site-name {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-heading);
letter-spacing: -0.01em;
}
.nav-links {
display: flex;
gap: clamp(1.25rem, 3vw, 2.5rem);
}
.nav-link {
font-size: 0.875rem;
color: var(--color-text-subtle);
cursor: pointer;
transition: color 0.5s ease, font 0.5s ease;
transition: color 0.2s;
text-decoration: none;
}
.headerOption:hover {
font: bold 2.3vw arial;
transition: color 0.5s ease, font 0.5s ease;
.nav-link:hover {
color: var(--color-heading-secondary);
}
/* ── Hero / Welcome Message ── */
#Hero {
max-width: 1100px;
margin: 0 auto;
padding: clamp(4rem, 10vw, 8rem) clamp(1.5rem, 5vw, 4rem) clamp(4rem, 8vw, 6rem);
}
#WelcomeMessage {
font: 3vw arial;
width: auto;
height: 40vw;
margin-top: 15vw;
text-align: left;
padding-left: 10vw;
padding-right: 10vw;
font-size: clamp(1.25rem, 2.2vw, 1.75rem);
max-width: 720px;
min-height: clamp(12rem, 28vw, 20rem);
line-height: 1.7;
color: var(--color-accent);
font-family: var(--font-mono);
}
/* ── Section Titles ── */
.section-title {
font-size: clamp(1.3rem, 2.2vw, 1.75rem);
font-weight: 700;
color: var(--color-heading);
margin: 0 0 clamp(1.5rem, 3vw, 2.5rem);
letter-spacing: -0.02em;
}
/* ── Demos ── */
#DemosBox {
margin: 10vw;
display: flex;
flex-direction: column;
justify-content: end;
width: 80%;
max-width: 1100px;
margin: clamp(4rem, 8vw, 8rem) auto;
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
#DemoHeader {
margin: 0;
color: rgb(212, 212, 212);
font-size: 4vw;
#buttonWrapper {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
gap: 0.75rem;
}
.demoButton {
margin-top: 5vw;
margin-right: 2vw;
padding-top: 0.5vw;
padding-bottom: 0.5vw;
padding: 0.875rem 1.25rem;
text-align: center;
border: none;
border-radius: 5vw;
width: 25vw;
height: 10vw;
font-size: 2vw;
background-color: rgb(104, 255, 0, 0.39);
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 0.875rem;
font-family: inherit;
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.demoButton:hover {
background: var(--color-surface-hover);
border-color: var(--color-border-hover);
color: var(--color-heading-secondary);
}
#MoreFiller {
cursor: default;
}
opacity: 0.3;
}
#MoreFiller:hover {
background: var(--color-surface);
border-color: var(--color-border);
color: var(--color-text);
}
/* ── Mobile ── */
@media (max-width: 600px) {
#Header {
padding: 1.25rem 1.25rem;
}
.site-name {
font-size: 0.95rem;
}
.nav-link {
font-size: 0.8rem;
}
}
@@ -0,0 +1,162 @@
body {
background: #1a1a1a;
min-height: 100vh;
margin: 0;
padding: 0;
}
.memory-lane-container {
max-width: 1000px;
margin: 0 auto;
padding: 4rem 2rem;
text-align: center;
}
.memory-lane-header {
color: #f5f5f5;
font-size: 2.5rem;
margin-bottom: 0.5rem;
font-weight: 300;
letter-spacing: 0.1em;
}
.memory-lane-subtitle {
color: #a0a0a0;
font-size: 1rem;
margin-bottom: 4rem;
font-weight: 300;
}
.events-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin: 3rem auto;
max-width: 900px;
padding: 0 1rem;
}
.event-card {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 2rem 1.5rem;
transition: all 0.3s ease;
cursor: pointer;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
align-items: center;
}
.event-card:hover {
background: #2f2f2f;
border-color: #4a4a4a;
transform: translateY(-2px);
}
.event-card.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.event-card.disabled:hover {
background: #2a2a2a;
border-color: #3a3a3a;
transform: none;
}
.event-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.9;
}
.event-title {
font-size: 1.2rem;
font-weight: 400;
color: #f5f5f5;
margin-bottom: 0.75rem;
letter-spacing: 0.05em;
}
.event-description {
font-size: 0.9rem;
color: #999;
line-height: 1.6;
font-weight: 300;
}
.coming-soon {
color: #666;
font-style: italic;
margin-top: 0.5rem;
font-size: 0.85rem;
}
.home-button {
margin-top: 4rem;
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
background: transparent;
color: #f5f5f5;
border: 1px solid #3a3a3a;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
letter-spacing: 0.05em;
}
.home-button:hover {
background: #2a2a2a;
border-color: #4a4a4a;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.memory-lane-header {
font-size: 2.5rem;
}
.memory-lane-subtitle {
font-size: 1.1rem;
}
.events-grid {
grid-template-columns: 1fr;
}
.event-icon {
font-size: 3rem;
}
.event-title {
font-size: 1.6rem;
}
}
+28
View File
@@ -0,0 +1,28 @@
:root {
/* Backgrounds */
--color-bg: #0a0a0a;
--color-surface: rgba(255, 255, 255, 0.02);
--color-surface-hover: rgba(255, 255, 255, 0.04);
--color-surface-raised: rgba(255, 255, 255, 0.05);
--color-surface-raised-hover: rgba(255, 255, 255, 0.08);
/* Text */
--color-heading: #fff;
--color-heading-secondary: #e0e0e0;
--color-text: #b0b0b0;
--color-text-muted: #aaa;
--color-text-subtle: #777;
/* Accent */
--color-accent: #09ff00;
--color-accent-hover: #5fff5f;
/* Borders */
--color-border: rgba(255, 255, 255, 0.07);
--color-border-hover: rgba(255, 255, 255, 0.15);
--color-accent-border: rgba(9, 255, 0, 0.4);
/* Typography */
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

@@ -0,0 +1,131 @@
const ChessAPI = {
async joinGame() {
const response = await fetch('/api/chess/JoinGame');
return await response.json();
},
async createCPUGame(difficulty, color = "random") {
const response = await fetch(`/api/chess/new/${difficulty}?color=${color}`);
return await response.json();
},
async getGameState(gameId) {
const response = await fetch(`/api/chess/${gameId}`);
return await response.json();
},
async getPgn(gameId) {
const response = await fetch(`/api/chess/${gameId}/pgn`);
if (!response.ok) throw new Error("PGN unavailable");
return await response.text();
},
async forfeit(gameId, playerId) {
await fetch("/api/chess/forfeit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ GameId: gameId, PlayerId: playerId })
});
},
async getLegalMoves(pieceId) {
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
if (!response.ok) throw new Error("API failed");
return await response.json();
},
async makeMove(moveDto) {
const response = await fetch("/api/chess/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(moveDto)
});
if (!response.ok) {
let message = await response.text();
throw new Error(message || "Invalid move or not your turn.");
}
// The move endpoint returns both the move result and the full resulting
// board state, so the caller can render without a follow-up fetch.
const data = await response.json();
if (!data.result.success) {
throw new Error(data.result.message || "Invalid move or not your turn.");
}
return data;
},
async handleMove(targetRow, targetCol) {
if (!GameState.selectedPiece) return;
const isPawn = GameState.selectedPiece.type === 0;
const reachedEnd = (Number(GameState.selectedPiece.color) === 0 && Number(targetRow) === 0) ||
(Number(GameState.selectedPiece.color) === 1 && Number(targetRow) === 0);
let choice = null;
if (isPawn && reachedEnd) {
choice = await ChessModals.promptPromotion();
if (!choice) return;
}
[targetRow, targetCol] = ChessUtils.flipCoordinates(targetRow, targetCol);
const moveDto = {
GameId: GameState.currentGameId,
PlayerId: GameState.currentPlayerId,
PieceId: GameState.selectedPiece.id,
SourceRow: GameState.selectedPiece.row,
SourceCol: GameState.selectedPiece.col,
TargetRow: targetRow,
TargetCol: targetCol,
PromotionChoice: choice
};
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
try {
const { result, state } = await this.makeMove(moveDto);
ChessBoard.renderState(state);
GameState.clearSelection();
ChessInteractions.clearHighlights();
this.alertGameStatusChange(result);
} catch (error) {
alert("❌ " + error.message);
}
},
alertGameStatusChange(moveResult) {
setTimeout(async () => {
let gameOver = false;
if (moveResult.isCheck) {
console.log("🛑 Check!");
}
if (moveResult.isCheckmate) {
alert("♟️ Checkmate!");
gameOver = true;
} else if (moveResult.isStalemate) {
alert("🤝 Stalemate!");
gameOver = true;
} else if (moveResult.isThreefoldRepetition) {
alert("🤝 Draw by threefold repetition!");
gameOver = true;
}
if (gameOver) {
showCopyPgn(GameState.currentGameId);
await ChessSignalR.leaveGame();
}
}, 500);
}
};
console.log("ChessAPI.js loaded");
@@ -0,0 +1,275 @@
// Material value per piece type, indexed by PieceType (0=Pawn .. 5=King).
const PIECE_VALUES = [1, 5, 3, 3, 9, 0];
const ChessBoard = {
// Render from a full game-state payload (the shape returned by the move/state
// endpoints and pushed over SignalR). Ignores state older than what's already
// shown, so a slow initial fetch can't clobber a move that arrived first.
renderState(state) {
if (!state || !GameState.shouldApply(state.version)) return;
this.renderPieces(state.pieces);
this.renderCapturedTrays(state.pieces, state.capturedPieces);
this.renderMoveList(state.sanHistory);
this.renderStatus(state);
GameState.setVersion(state.version);
},
// Two-column numbered move list (white move, black move), latest highlighted.
renderMoveList(sanHistory) {
const list = document.getElementById("moveList");
if (!list) return;
const san = sanHistory ?? [];
if (san.length === 0) {
list.innerHTML = '<p class="movePlaceholder">Moves will appear here once a game begins.</p>';
return;
}
list.innerHTML = "";
for (let i = 0; i < san.length; i += 2) {
const row = document.createElement("div");
row.className = "moveRow";
const num = document.createElement("span");
num.className = "moveNum";
num.textContent = `${i / 2 + 1}.`;
row.appendChild(num);
row.appendChild(this.moveCell(san[i], i === san.length - 1));
if (i + 1 < san.length)
row.appendChild(this.moveCell(san[i + 1], i + 1 === san.length - 1));
list.appendChild(row);
}
list.scrollTop = list.scrollHeight;
},
moveCell(san, isLatest) {
const cell = document.createElement("span");
cell.className = isLatest ? "moveSan latest" : "moveSan";
cell.textContent = san;
return cell;
},
renderStatus(state) {
let text;
let alert = false;
if (state.isCheckmate) {
const winner = state.currentPlayer === "White" ? "Black" : "White";
text = `Checkmate — ${winner} wins`;
alert = true;
} else if (state.isStalemate) {
text = "Draw — stalemate";
alert = true;
} else if (state.isThreefoldRepetition) {
text = "Draw — threefold repetition";
alert = true;
} else {
text = `${state.currentPlayer} to move`;
if (state.isCheck) {
text += " — check";
alert = true;
}
}
// Mirror to both the desktop panel status and the mobile bar status.
["statusLine", "mobileStatus"].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.classList.toggle("alert", alert);
});
},
renderPieces(pieces) {
this.clearAllSquares();
this.renderCoordinateLabels();
this.placePieces(pieces);
this.setupSquareEventHandlers();
this.highlightPreviousMove();
},
// Show each side's captured pieces and the leading side's material advantage,
// arranged so the current player's tray sits below the board.
renderCapturedTrays(activePieces, capturedPieces) {
const captured = capturedPieces ?? [];
// A captured piece's color is the side that lost it, so White's haul is the
// captured Black pieces, and vice versa.
const whiteCaptured = captured.filter(p => p.color === 1);
const blackCaptured = captured.filter(p => p.color === 0);
// Net material from pieces still on the board, so promotions count correctly.
const advantage = (activePieces ?? []).reduce(
(sum, p) => sum + (p.color === 0 ? PIECE_VALUES[p.type] : -PIECE_VALUES[p.type]), 0);
const white = { captured: whiteCaptured, advantage: Math.max(advantage, 0) };
const black = { captured: blackCaptured, advantage: Math.max(-advantage, 0) };
const isWhite = GameState.currentPlayerIsWhite !== false;
this.fillCapturedTray("bottom", isWhite ? white : black);
this.fillCapturedTray("top", isWhite ? black : white);
},
fillCapturedTray(position, side) {
const tray = document.getElementById(`captured-${position}`);
const badge = document.getElementById(`advantage-${position}`);
if (!tray || !badge) return;
tray.innerHTML = "";
[...side.captured]
.sort((a, b) => PIECE_VALUES[a.type] - PIECE_VALUES[b.type])
.forEach(piece => {
const img = document.createElement("img");
img.src = ChessUtils.getPieceImageUrl(piece);
img.alt = piece.type;
img.className = "capturedPiece";
img.draggable = false;
tray.appendChild(img);
});
badge.textContent = side.advantage > 0 ? `+${side.advantage}` : "";
},
clearAllSquares() {
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
square.innerHTML = "";
}
},
placePieces(pieces) {
pieces.forEach(piece => {
const [r, c] = ChessUtils.flipCoordinates(piece.row, piece.col);
const index = r * 8 + c;
const square = document.getElementById(`square-${index}`);
if (!square) return;
const img = document.createElement("img");
img.src = ChessUtils.getPieceImageUrl(piece);
img.alt = piece.type;
img.classList.add("chessPiece");
this.setupPieceDragEvents(img, piece);
this.setupPieceClickEvent(img, piece);
square.appendChild(img);
});
},
setupPieceDragEvents(img, piece) {
img.draggable = true;
img.ondragstart = async (e) => {
GameState.setSelectedPiece(piece);
try {
const moves = await ChessAPI.getLegalMoves(piece.id);
GameState.setLegalMoves(moves);
ChessInteractions.highlightSelected(piece.row, piece.col);
ChessInteractions.highlightLegalMoves(moves);
} catch (err) {
console.error(err);
}
e.dataTransfer.setData("text/plain", JSON.stringify({
srcRow: piece.Row,
srcCol: piece.Col
}));
};
img.ondragend = () => ChessInteractions.clearHighlights();
},
setupPieceClickEvent(img, piece) {
img.onclick = (e) => {
e.stopPropagation();
ChessInteractions.handlePieceClick(piece);
console.log("Clicked on:", piece);
};
},
setupSquareEventHandlers() {
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
square.classList.remove("previous-start", "previous-end");
if (!square.classList.contains("legal")) {
square.onclick = () => ChessInteractions.clearHighlights();
}
}
},
highlightPreviousMove() {
if (GameState.previousMoveStart && GameState.previousMoveEnd) {
const [startRow, startCol] = ChessUtils.flipCoordinates(...GameState.previousMoveStart);
const [endRow, endCol] = ChessUtils.flipCoordinates(...GameState.previousMoveEnd);
const startIndex = startRow * 8 + startCol;
const endIndex = endRow * 8 + endCol;
document.getElementById(`square-${startIndex}`)?.classList.add("previous-start");
document.getElementById(`square-${endIndex}`)?.classList.add("previous-end");
}
},
setupDragAndDrop() {
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
square.ondragover = (e) => {
e.preventDefault();
};
square.ondrop = (e) => {
e.preventDefault();
if (!GameState.selectedPiece) return;
const index = parseInt(square.id.split('-')[1], 10);
const targetRow = Math.floor(index / 8);
const targetCol = index % 8;
ChessAPI.handleMove(targetRow, targetCol);
};
}
},
renderCoordinateLabels() {
const files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
const ranks = ['8', '7', '6', '5', '4', '3', '2', '1'];
// If player is black, reverse the coordinates
if (!GameState.currentPlayerIsWhite) {
files.reverse();
ranks.reverse();
}
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
const row = Math.floor(i / 8);
const col = i % 8;
// Add rank label (1-8) on the leftmost column
if (col === 0) {
const rankLabel = document.createElement('span');
rankLabel.className = 'coordinate-label row-label';
rankLabel.textContent = ranks[row];
square.appendChild(rankLabel);
}
// Add file label (a-h) on the bottom row
if (row === 7) {
const fileLabel = document.createElement('span');
fileLabel.className = 'coordinate-label col-label';
fileLabel.textContent = files[col];
square.appendChild(fileLabel);
}
}
}
};
console.log("ChessBoard.js loaded");
@@ -0,0 +1,59 @@
const ChessInteractions = {
async handlePieceClick(piece) {
this.clearHighlights();
GameState.setSelectedPiece(piece);
try {
const moves = await ChessAPI.getLegalMoves(piece.id);
GameState.setLegalMoves(moves);
this.highlightSelected(piece.row, piece.col);
this.highlightLegalMoves(moves);
} catch (err) {
console.error("Error in handlePieceClick:", err);
}
},
highlightSelected(row, col) {
const [r, c] = ChessUtils.flipCoordinates(row, col);
const index = r * 8 + c;
document.getElementById(`square-${index}`).classList.add("selected");
},
highlightLegalMoves(moves) {
moves.forEach(move => {
const [r, c] = ChessUtils.flipCoordinates(move.row, move.col);
const index = r * 8 + c;
const square = document.getElementById(`square-${index}`);
square.classList.add("legal");
const img = square.querySelector("img");
if (img) img.onclick = null;
let overlay = document.createElement("div");
overlay.className = "legalOverlay";
square.appendChild(overlay);
square.onclick = () => ChessAPI.handleMove(r, c);
});
},
clearHighlights() {
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
const overlay = square.querySelector(".legalOverlay");
if (overlay) square.removeChild(overlay);
if (square.classList.contains("legal")) {
square.classList.remove("legal");
square.onclick = null;
}
square.classList.remove("selected");
}
GameState.clearSelection();
}
};
console.log("ChessInteractions.js loaded");
@@ -0,0 +1,39 @@
const ChessModals = {
promptPromotion() {
return new Promise(resolve => {
let color = 1;
if (GameState.currentPlayerIsWhite)
color = 0;
ChessUtils.updatePromotionModalImages(color);
document.getElementById("promotionModal").style.display = "block";
window.selectPromotion = (piece) => {
document.getElementById("promotionModal").style.display = "none";
resolve(piece);
};
});
},
promptDifficulty() {
return new Promise(resolve => {
document.getElementById("difficultyModal").style.display = "block";
window.selectDifficulty = (difficulty) => {
document.getElementById("difficultyModal").style.display = "none";
resolve(difficulty);
};
});
},
promptColor() {
return new Promise(resolve => {
document.getElementById("colorModal").style.display = "block";
window.selectColor = (color) => {
document.getElementById("colorModal").style.display = "none";
resolve(color);
};
});
}
};
console.log("ChessModals.js loaded");
@@ -0,0 +1,71 @@
const ChessSignalR = {
connection: null,
async setupConnection() {
this.connection = new signalR.HubConnectionBuilder()
.withUrl("/chessHub")
.configureLogging(signalR.LogLevel.Information)
.build();
this.connection.onclose(err => {
console.error("❌ SignalR connection closed:", err?.message);
});
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => {
await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state);
});
this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => {
await this.handleGameOver(gameId, winner, reason);
});
try {
await this.connection.start();
console.log("✅ SignalR connected");
await this.connection.invoke("JoinWebsocketGroup", GameState.currentGameId);
} catch (err) {
console.error("❌ SignalR failed to start or join:", err);
}
},
async handleMoveUpdate(gameId, moveDto, moveResultDto, state) {
if (gameId !== GameState.currentGameId) return;
// Drop the echo of our own move and any out-of-order delivery.
if (!GameState.shouldApply(state?.version)) return;
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
ChessBoard.renderState(state);
ChessAPI.alertGameStatusChange(moveResultDto);
},
async handleGameOver(gameId, winner, reason) {
if (gameId !== GameState.currentGameId) return;
const youWon = (winner === "White") === GameState.currentPlayerIsWhite;
if (reason === "forfeit")
alert(youWon ? "🏳️ Your opponent forfeited — you win!" : "🏳️ You forfeited this game.");
showCopyPgn(gameId);
await this.leaveGame();
},
async leaveGame() {
if (this.connection) {
await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId);
await this.connection.stop();
this.connection = null;
}
},
async stopConnection() {
if (this.connection) {
await this.connection.stop();
this.connection = null;
}
}
};
console.log("ChessSignalR.js loaded");
@@ -0,0 +1,44 @@
const ChessUtils = {
flipCoordinates(row, col) {
if (!GameState.currentPlayerIsWhite) {
return [7 - row, 7 - col];
}
return [row, col];
},
getCookie(name) {
const value = document.cookie.split('; ')
.find(row => row.startsWith(name + '='));
return value ? value.split('=')[1] : null;
},
setCookie(name, value, maxAge = 86400) {
document.cookie = `${name}=${value}; path=/; max-age=${maxAge}`;
},
getPieceImageUrl(piece) {
const basePath = "/images/Chess Images/";
const color = piece.color === 0 ? "White" : "Black";
const typeMap = {
0: "Pawn",
1: "Rook",
2: "Knight",
3: "Bishop",
4: "Queen",
5: "King"
};
return basePath + color + typeMap[piece.type] + ".svg";
},
updatePromotionModalImages(color) {
const pieceNames = ["Queen", "Rook", "Bishop", "Knight"];
const buttons = document.querySelectorAll("#promotionModal button img");
buttons.forEach((img, index) => {
img.src = `/images/Chess Images/${color === 0 ? "White" : "Black"}${pieceNames[index]}.svg`;
});
}
};
console.log("ChessUtils.js loaded");

Some files were not shown because too many files have changed in this diff Show More