aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-03 19:46:54 -0400
committerhistoria <[not public]>2026-10-03 19:46:54 -0400
commit3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 (patch)
tree9ba1cd5f2aef6ef7c54fdf5b02a780604b5af80b
parentc6d4702c0be4d2b37e1d82ff96265c52847c3f9a (diff)
downloadthehouseoficarus-3a39a020e4449bc4d6f728ef837cffb9f7bd0c56.tar.gz
feat: new casino game: plinko
-rw-r--r--AGENTS.md2
-rw-r--r--building_guide/admin.md12
-rw-r--r--building_guide/behaviors.md10
-rw-r--r--building_guide/construction.md5
-rw-r--r--building_guide/courses.md7
-rw-r--r--building_guide/hacking.md40
-rw-r--r--building_guide/hazards.md45
-rw-r--r--building_guide/mobs.md21
-rw-r--r--building_guide/objects.md15
-rw-r--r--building_guide/rooms.md46
-rw-r--r--building_guide/triggers.md16
-rw-r--r--data/help/casino.yaml8
-rw-r--r--data/help/plinko.yaml47
-rw-r--r--data/rooms/casino/6199.yaml4
-rw-r--r--data/rooms/casino/6200.yaml12
-rw-r--r--internal/casino/house_edge.go7
-rw-r--r--internal/casino/plinko.go395
-rw-r--r--internal/casino/plinko_test.go218
-rw-r--r--internal/casino/registry.go10
-rw-r--r--internal/casino/types.go2
-rw-r--r--internal/game/blackjack_rules.go4
-rw-r--r--internal/game/casino.go10
-rw-r--r--internal/game/cmd_registry.go7
-rw-r--r--internal/game/look_casino.go4
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/plinko_test.go261
-rw-r--r--internal/game/render_plinko.go213
27 files changed, 1283 insertions, 140 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 62ca10c..30d462e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,3 +1,3 @@
-./building_guide contains documentation for human builders on how to use worldbuilding tools. DO NOT create new documents here unless specifically asked. It is NOT meant to describe how all game systems work.
+./building_guide contains documentation for human builders on how to use worldbuilding tools. DO NOT create new documents here unless specifically asked. DO NOT update building_guide documents unless it's specifically instructions on how a builder/admin creates and populates the world (e.g. YAML examples for making objects and placing them in the world). It is NOT meant to describe how all game systems work.
Do not update AGENTS.md
diff --git a/building_guide/admin.md b/building_guide/admin.md
index a99535a..3167147 100644
--- a/building_guide/admin.md
+++ b/building_guide/admin.md
@@ -273,11 +273,9 @@ even across sessions.
#### Live editing and maintenance
-Editor API requests (including undo/redo) execute as one game-owner task:
-file writes and their runtime callbacks/reloads cannot interleave with a tick
-or player command. Large edits briefly pause gameplay; slow browser uploads
-and downloads do not. Requests are limited to 8 MiB and rejected during game
-shutdown.
+Editor API requests (including undo/redo) execute as a single serialized task, so
+large edits briefly pause gameplay while slow browser uploads and downloads do
+not. Requests are limited to 8 MiB and rejected during game shutdown.
This does not make every edit automatically hot-reloadable. Existing reload
requirements still apply to definition editors and raw Files edits. Use the
@@ -308,9 +306,7 @@ The colors page is the single source of truth for everything the game paints wit
**Constants** — colors the engine renders conditionally that players can **never** override: HP-bar tiers (green/yellow/red by remaining %), level-difference bands (white/green/red by how far a target's level is from the viewer's), skill-level tier colors on the datapad, battery/run-energy bars, tech status indicators (`ON`/`off`/`locked`), tech/buff `[ON]`/`[BUFF]` tags and stat names, the score panel "max value" trailing numbers, shared table-header chrome (`Skill`, `Level`, `XP`, `Option`, `Value`, `Description` etc.), the production-menu locked-row tint, the APS datapad "here" highlight, the mob inline `n/maxhp` value, the `(protected)` tag, and the god-mode `[LOCAL]`/`[GLOBAL]`/`(HIDDEN)` tags. These are stored in a separate `constant_colors` block in `config.yaml`.
-**Why constants are separate from player colors.** The in-game `colors` command only knows about names listed in `config.DefaultColors()`. Constants live in a different map (`constant_colors`), which that command never reads. So if a player types `colors hp_bar_high FF`, the game answers "Unknown color target: hp_bar_high" and refuses — there is no path for a player to set one. Editing a constant here is the only way to change it short of editing `config.yaml` directly.
-
-**Save / Reset.** One shared **Save Colors** button persists both sections to `config.yaml` (a backup snapshot of the previous file is written first). **Reset all to defaults** restores both `default_colors` and `constant_colors` to the built-in values from `internal/config/config.go` and saves immediately. **Export JSON** / **Import JSON** round-trip both maps; the import payload may be either the layered `{colors:{...}, constant_colors:{...}}` shape (the format Export produces) or a legacy flat `{category: spec}` object, in which case only player colors are applied.
+**Save / Reset.** One shared **Save Colors** button persists both sections to `config.yaml` (a backup snapshot of the previous file is written first). **Reset all to defaults** restores both `default_colors` and `constant_colors` to their built-in values and saves immediately. **Export JSON** / **Import JSON** round-trip both maps; the import payload may be either the layered `{colors:{...}, constant_colors:{...}}` shape (the format Export produces) or a flat `{category: spec}` object, in which case only player colors are applied.
**Preview.** The right pane renders example output (a full room look, two combat rounds, chat, drops, ticks, assassin/safespot, and a Constants demo block) using the live specs you have staged. The combat HP bars in the preview paint with the real `hp_bar_high` / `hp_bar_med` / `hp_bar_empty` constants, and the `(level …)` annotations in the Room Look paint with `level_diff_*` — so editing a constant is reflected immediately in the same example lines an admin would recognize from gameplay.
diff --git a/building_guide/behaviors.md b/building_guide/behaviors.md
index 26c12ec..976ba3d 100644
--- a/building_guide/behaviors.md
+++ b/building_guide/behaviors.md
@@ -488,20 +488,20 @@ pass fires and its `steps` run as a scripted sequence (first-match-wins).
|-------|-------|-------------|
| `lock` | all | `true` = atomic (player can only `quit`) + resumable on reconnect. Default `false` = interruptable, not persisted. |
| `item_id` | on_use/on_look/on_kill | `on_use`: required item (`use <item> on <obj>`); empty = bare `use <obj>`. `on_look`: only fires if carrying this item. `on_kill`: only fires if wielding this weapon. |
-| `condition` | all | Optional Trigger-level gate (Condition struct). |
+| `condition` | all | Optional Trigger-level gate (Condition). |
| `steps` | all | Ordered list of Step entries run as a scripted sequence. |
| `on_player_flag` | flag-trigger files only | Player flag this trigger watches. |
| `on_global_flag` | flag-trigger files only | Global flag this trigger watches. |
| `value` | flag triggers | Optional value-match filter. |
-Talk node/option actions still use the NodeAction shape (`action:` with the effect fields
-inline) — those have not changed.
+Talk node/option actions use the NodeAction shape (`action:` with the effect fields
+inline).
#### Step vocabulary
Each Step can carry a `wait` (ticks to pause before the step fires) and any combination of
the effects below. All effects on a step fire together when the wait elapses. Messages are
-plain strings — neither `{message, delay}` maps nor the old `delay` field exist.
+plain strings.
| Field | Type | Description |
|-------|------|-------------|
@@ -535,7 +535,7 @@ Conditions gate a Trigger (Trigger-level `condition`) or an individual step. A b
| `not` | `true` inverts the entire condition |
| `has_item` | Passes when the player carries this item |
| `min_credits` | Passes when the player has at least this many credits |
-| `room` | **New.** Passes when the triggering player is currently in this room — use to scope flag-change triggers |
+| `room` | Passes when the triggering player is currently in this room — use to scope flag-change triggers |
| `all_of` | List of sub-conditions; all must pass |
| `any_of` | List of sub-conditions; any one must pass |
diff --git a/building_guide/construction.md b/building_guide/construction.md
index 07b8f7f..5e2c898 100644
--- a/building_guide/construction.md
+++ b/building_guide/construction.md
@@ -26,9 +26,8 @@ description:
inroom_description: "An estate directory terminal stands in the center of the neighborhood."
```
-The directory is handled by Go code: when a player looks at or uses it, it scans character
-YAML files for the `owns_house_local_neighborhood` player flag and lets homeowners teleport
-into their house.
+The directory scans character YAML files for the `owns_house_local_neighborhood`
+player flag and lets homeowners teleport into their house.
### Tools
diff --git a/building_guide/courses.md b/building_guide/courses.md
index 3f571e0..eecfb00 100644
--- a/building_guide/courses.md
+++ b/building_guide/courses.md
@@ -4,7 +4,7 @@ Agility courses are defined in `data/courses/<id>.yaml`. Each course defines a s
**The filename is the ID** (`vent_shaft.yaml` → `vent_shaft`); the loader derives it from the filename. Do not put an `id:` field in the file — it is ignored.
-> Editing courses is done from the **Map screen** in the admin web GUI: select an obstacle room and use its **Course** side-panel tab to create a course, add the room as a step, edit all of its parameters, or remove it. The legacy standalone "Courses" editor tab has been removed.
+> Editing courses is done from the **Map screen** in the admin web GUI: select an obstacle room and use its **Course** side-panel tab to create a course, add the room as a step, edit all of its parameters, or remove it.
>
> When a room is added as a course step, the admin GUI auto-creates **always-blocked one-way exits** between consecutive obstacle rooms so the in-game map can lay them out correctly. The direction of these exits is chosen by the builder. Always-blocked exits are blocked to players (movement is refused with the standard "blocked" message and they show "(blocked)" in `look`) but visible on the map — they exist solely for map rendering. On the map, course links appear as blue dashed one-way arrows (colored via the `map_agility` theme token).
@@ -66,7 +66,7 @@ obstacles:
| Field | Type | Description |
| ------------ | -------- | -------------------------------------------------------------------------- |
| `message` | string | Text printed when this phase advances |
-| `delay` | float64 | Ticks to wait before printing this phase (supports fractional via `engine.ToTicks`) |
+| `delay` | float64 | Ticks to wait before printing this phase (fractional values allowed) |
| `fail_check` | bool | If true, the obstacle's failure roll happens on this phase (set on at most one phase) |
### Phase Advancement
@@ -128,4 +128,5 @@ exits:
| `leap` | leaping |
| `slide` | sliding |
-Obstacle verbs are auto-registered at startup from all course YAML files. No changes to `cmd_registry.go` needed when adding new courses — any verb in a `data/courses/` file will work. \ No newline at end of file
+Obstacle verbs are auto-registered at startup from all course YAML files — any verb in a
+`data/courses/` file will work without additional configuration.
diff --git a/building_guide/hacking.md b/building_guide/hacking.md
index dc4372b..b45363f 100644
--- a/building_guide/hacking.md
+++ b/building_guide/hacking.md
@@ -1,8 +1,8 @@
## Hacking
-Hacking minigames are played by jacking into terminal objects. Terminals are YAML objects that are registered in Go's `terminalDefs` map.
-
-**The filename is the ID** (`terminal_basic.yaml` → `terminal_basic`); like all objects, the loader derives the ID from the filename — don't add an `id:` field (see `objects.md`).
+Hacking minigames are played by jacking into terminal objects. Terminals are YAML
+objects; the filename is the ID (`terminal_basic.yaml` → `terminal_basic`), and
+like all objects you should not add an `id:` field (see `objects.md`).
### Terminal Object YAML
@@ -13,38 +13,20 @@ description: "A battered terminal with a cracked screen. {28}[Level 1 Hacking]{/
inroom_description: "A {28}basic terminal{/} hums quietly against the wall."
```
-- `id` must match a key in `terminalDefs` (Go code in `internal/game/hacking.go`)
- `name` is used for matching in the `jack` command
- `color` controls the display color
- `description` is shown when examining the terminal
- `inroom_description` is shown in room listings
-- No `behavior` field — terminals are registered in Go, not YAML behaviors
-
-### Adding a New Terminal
-
-1. Create an object YAML file in `data/objects/terminal_<name>.yaml`
-2. Add an entry to `terminalDefs` in `internal/game/hacking.go`
-3. Optionally create a new minigame implementation in `internal/game/hacking_<name>.go`
-4. Place the terminal object in a room's `objects:` list
-
-### Adding a New Minigame
+- Terminals have no behavior block; each terminal corresponds to a minigame
+ registered in the game code
-New minigames implement the `HackingMinigame` interface:
+### Placing a Terminal
-```go
-type HackingMinigame interface {
- Init(level int) string
- HandleInput(input string) (output string, done bool, won bool)
- Name() string
-}
-```
-
-Optionally implement `XPBonuser` for bonus XP:
+Add the terminal object to a room's `objects:` list:
-```go
-type XPBonuser interface {
- BonusXP() int
-}
+```yaml
+objects:
+ - id: terminal_basic
```
-
+Adding a brand-new hacking minigame is an engine change, not a world-data change.
diff --git a/building_guide/hazards.md b/building_guide/hazards.md
index 04333c9..3b72840 100644
--- a/building_guide/hazards.md
+++ b/building_guide/hazards.md
@@ -1,17 +1,9 @@
## Hazards
A **hazard** is an environmental threat attached to a room — a sandstorm, solar
-radiation, falling rocks, toxic spores. It rolls an "attack" against everyone in
-the room every few ticks using the **same combat formulas as a mob**: the hazard
-supplies the accuracy and max hit, and the player defends with their Defense
-level plus the equipment defense bonus selected by the hazard's attack type. No
-new combat math, no special gear — your existing armour and Defense skill are
-what keep you alive.
-
-Hazards are **shared, ID-referenced definitions** (like drop tables and modules):
-define a hazard once in `data/hazards/<id>.yaml`, then reference it from any number
-of rooms with `hazard: <id>` (see `rooms.md`). This makes one hazard reusable
-across a whole area and lets a future `examine` command resolve it by name.
+radiation, falling rocks, toxic spores. Define it once in
+`data/hazards/<id>.yaml`, then reference it from any number of rooms with
+`hazard: <id>` (see `rooms.md`).
**The filename is the ID** (`solar_radiation.yaml` → reference it as `hazard: solar_radiation`); the loader derives it from the filename. Do not put an `id:` field in the file — it is ignored.
@@ -76,37 +68,8 @@ attacked — they only attack the player.
| Field | Description |
|---|---|
| `name` | Display name used in warnings and default messages |
-| `description` | Flavour text (for a future `examine`) |
+| `description` | Flavour text for the hazard |
| `warn_message` | Shown when warning the player about the area |
| `hit_message` | Message on a hit; include a single `%d` to show the damage |
| `miss_message` | Message on a miss |
| `required_item` | If set and equipped, the hazard is fully negated for that player |
-
-### How it behaves
-
-- **Applies to everyone in the room, always** — idle, passing through, working a task,
- or fighting a mob. Strong incentive to work fast, take cover, or leave.
-- **Tech protection applies automatically.** Because the hazard has an attack type, the
- matching protection tech reduces its damage: Kinetic Barrier (melee types),
- Projectile Screen (`ranged`), Neural Firewall (`science`).
-- **Safespots block all hazard damage.** A hidden player behind a safespot object takes
- no hazard damage, regardless of hazard type. Note that doing a *melee* attack/work step
- forces you out of cover — so ranged/science work lets you stay sheltered, melee does not.
-- **Right gear negates it.** If `required_item` is set and equipped, the hazard is skipped
- for that player entirely. The item must exist (validated at startup).
-- **Hazards never interrupt movement.** Unlike a mob hit (which aborts a flee with
- "Can't escape!"), a hazard hit does not touch your walk — you can keep moving out of
- the danger zone while taking hits.
-
-### Entry warning
-
-Players walking from a **safe** room into a **hazardous** one get a `[Y/n]` confirmation
-(pressing return defaults to yes; anything other than empty/`y`/`yes` cancels). Moving
-between two hazardous rooms does not re-prompt. Players can disable the prompt with the
-account option `danger_warning` (`option danger_warning false`).
-
-### Examine (forward-compatible)
-
-Because the hazard is a shared def referenced by ID, a future `examine sandstorm` can
-resolve the current room's `hazard` ID, load the def, and render its attack type, accuracy,
-max hit and description — no hidden objects required.
diff --git a/building_guide/mobs.md b/building_guide/mobs.md
index 833fbfd..cbcd728 100644
--- a/building_guide/mobs.md
+++ b/building_guide/mobs.md
@@ -67,7 +67,7 @@ combat:
strength: 3
defense: 3
speed: 5
- max_melee_hit: 1 # OSRS-derived: ((strength+9)*(strength_bonus+64)+320)/640
+ max_melee_hit: 1 # max hit with melee (for melee-attacking mobs)
bonuses:
attack_bonus: 3 # equipment-equivalent attack bonus for mob's attack roll
strength_bonus: 2 # equipment-equivalent strength bonus for mob's max hit
@@ -97,9 +97,9 @@ All combat stats live under `combat.stats:`
| `ranged` | Ranged level (for ranged/science mobs) |
| `science` | Science level |
| `speed` | Attack speed in ticks (default 5) |
-| `max_melee_hit` | Maximum melee hit (for melee-attacking mobs). Computed as ((strength+9)*(strength_bonus+64)+320)/640 |
-| `max_ranged_hit` | Maximum ranged hit (for ranged-attacking mobs). Computed as ((ranged+9)*(ranged_bonus+64)+320)/640 |
-| `max_science_hit` | Maximum science hit (for science-attacking mobs). A set value — magic max hits are not level-derived in OSRS |
+| `max_melee_hit` | Maximum melee hit (for melee-attacking mobs) |
+| `max_ranged_hit` | Maximum ranged hit (for ranged-attacking mobs) |
+| `max_science_hit` | Maximum science hit (for science-attacking mobs). A set value, not level-derived |
#### Bonuses (`combat.stats.bonuses:`)
@@ -352,7 +352,7 @@ Mobs without these fields work normally (assassin_level defaults to 0, finishing
### Task Mobs (Worksites)
-A task mob is a non-violent "worksite" — build a solar panel, survey flora, prospect a rock — that reuses the entire combat engine. Set `combat.kind: task`. Mechanically `combat.stats.hp` is the work pool that drains to 0 to **complete** the work; the player sees a progress bar filling toward 100%. A task mob **never attacks back** — the danger (if any) comes from a room `hazard:` (see `hazards.md`). Use the `work` command (or `attack`) on it.
+A task mob is a non-violent "worksite" — build a solar panel, survey flora, prospect a rock. Set `combat.kind: task` and treat `combat.stats.hp` as the amount of work required to complete it. A task mob **never attacks back** — the danger (if any) comes from a room `hazard:` (see `hazards.md`). Use the `work` command (or `attack`) on it.
Task-specific fields live in the root-level `task:` block:
@@ -402,10 +402,11 @@ drops: # the "payment" for the labour — same drop sy
Key points:
-- **All combat styles work.** Melee, ranged (consumes ammo as "supplies") and science decks (consume junk) all apply progress. The per-type `*_defense` values steer which method is *efficient* without forbidding any — a rock might have low `crush_defense` but high `stab_defense`.
-- **XP and buffs are identical to combat** — driven by your attack style (accurate/aggressive/defensive/balanced) and split into the combat skills. This is a non-violent way to train Accuracy/Strength/Defense/Ranged/Science/Hitpoints.
-- **No special equipment.** Task mobs are designed around the weapons and bonuses you already have. There is no separate "tool" equipment set.
-- **Progress decays exactly like inverted mob regen.** A task you stop working on slowly loses progress over time (the same regen that heals combat mobs, displayed inverted).
-- **One worker at a time**, the same 1-v-1 lock as combat.
+- All combat styles apply progress: melee, ranged (consumes ammo), and science decks
+ (consume junk). The per-type `*_defense` values steer which method is *efficient*
+ without forbidding any — a rock might have low `crush_defense` but high `stab_defense`.
+- `combat.stats.hp` is the amount of work required, not a health bar. Task mobs never
+ attack back; danger comes from a room `hazard:` if you want any.
+- `drops` are the payment for completing the work, using the same drop system as combat.
---
diff --git a/building_guide/objects.md b/building_guide/objects.md
index 958938d..10d50c1 100644
--- a/building_guide/objects.md
+++ b/building_guide/objects.md
@@ -449,15 +449,12 @@ Mobs must have a `size` field for safespot blocking to work (small < medium < la
massive, default medium). A safespot with `max_block_size: large` blocks mobs of size small,
medium, and large, but not massive.
-#### Combat integration
-
-- A safespot blocks melee attacks from mobs within `max_block_size`.
-- When a mob's melee is blocked but it has a ranged/science attack type, it switches to its
- strongest ranged/science attack.
-- Mobs exceeding `max_block_size` are never blocked and always use melee.
-- Melee attacks from a safespotted player force them out of cover.
-- Hiding in combat takes 4 ticks; taking a hit during this cancels the hide.
-- Hiding out of combat takes 1 tick.
+#### Interaction notes
+
+- A safespot blocks melee attacks from mobs up to `max_block_size`; larger mobs always use
+ melee.
+- A blocked mob with a ranged/science attack type switches to its strongest ranged/science
+ attack.
- Safespots block all hazard damage (see `hazards.md`).
#### Option: safespot_alert
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
index 9cb93b1..f1e1712 100644
--- a/building_guide/rooms.md
+++ b/building_guide/rooms.md
@@ -246,7 +246,7 @@ Multiplayer table fields (`casino_tables`):
| Field | Type | Default | Notes |
|---|---|---|---|
| `id` | string | — | Required. Nonempty, no surrounding whitespace, unique among the room's tables and machines. |
-| `game` | string | — | Required. `blackjack`, `baccarat`, `roulette`, `craps`, `paigow`, or `video_poker`. |
+| `game` | string | — | Required. `blackjack`, `baccarat`, `roulette`, `craps`, `paigow`, `video_poker`, or `plinko`. |
| `max_players` | int | 8 | 0–64. Video poker is forced to 1; Pai Gow caps at 6. |
| `betting_ticks` | int | 20 | 0–3600. |
| `min_bet` | int | 1 | Must not exceed `max_bet`. |
@@ -255,6 +255,27 @@ Multiplayer table fields (`casino_tables`):
Game rules are nested in a block named after the game. `id` and `game` are the
only required keys; omitted numeric fields keep their defaults.
+#### Plinko
+
+Plinko is a single-seat fixture under `casino_tables`, addressed with `play plinko`:
+
+```yaml
+casino_tables:
+ - id: plinko
+ game: plinko
+ min_bet: 1
+ max_bet: 10000
+ plinko:
+ rtp: 98
+```
+
+| Field | Type | Default | Notes |
+|---|---|---|---|
+| `rtp` | int | 98 | Target return-to-player percentage, 31–100. |
+
+`max_players` may only be omitted, `0`, or `1`. Bet limits are per ball, and
+`max_bet` cannot exceed 10000000.
+
#### Blackjack
```yaml
@@ -402,7 +423,7 @@ casino_machines:
|---|---|---|---|
| `id` | string | — | Required; same rules as table IDs. |
| `game` | string | — | Required; only `slots`. |
-| `variant` | string | `huff_and_even_more_puff` | Registered in Go (`slotVariants` in `internal/casino/registry.go`); an unregistered name is rejected. |
+| `variant` | string | `huff_and_even_more_puff` | Must be a registered slot variant; an unregistered name is rejected. |
| `min_bet` | int | 1 | |
| `max_bet` | int | 1000000 | Must be at least `min_bet`. |
| `spin_ticks` | int | 2 | 0–3600. |
@@ -422,8 +443,7 @@ invalid room casino configurations. Unknown fields are rejected.
seat counts, split counts, decks, and payouts are rejected.
- Payouts and shuffle fractions must be finite (no NaN or infinity).
- Editing a room hot-reconfigures an **idle** table or machine on the next
- lookup; an occupied game keeps its live rules until it becomes idle, so a
- committed wager is never discarded.
+ lookup.
### Objects — interactive fixtures
@@ -532,9 +552,9 @@ on_enter:
#### Timed sequences
-`wait: N` on a step is the number of ticks to pause **before** that step fires (replaces the
-old `delay`). Messages are plain strings; effects on a step fire together when the wait
-elapses. A step with no `wait` fires on the next tick.
+`wait: N` on a step is the number of ticks to pause **before** that step fires. Messages
+are plain strings; effects on a step fire together when the wait elapses. A step with no
+`wait` fires on the next tick.
#### Step actions
@@ -558,12 +578,11 @@ on_enter:
despawn_on_leave: true
```
-#### First-match-wins and migration
+#### First-match-wins
-Entries in `on_enter` are walked top-to-bottom; the first whose `condition` passes fires
-and its steps run. The old on_enter ran **every** matching step, so migration wraps the old
-step list into a single Trigger entry to preserve behavior. For new content where steps
-are mutually exclusive, use multiple entries with distinct conditions.
+Entries in `on_enter` are walked top-to-bottom; the first whose `condition` passes
+fires and its steps run. For new content where steps are mutually exclusive, use
+multiple entries with distinct conditions.
Notes:
- `wait: 0` (or omitted) fires on the next tick.
@@ -592,8 +611,7 @@ on_exit:
Use on_exit for parting messages, closing out a spawn when a player departs, or recording
that the player has passed through an area. It obeys the same first-match-wins rule and the
-same Step vocabulary as on_enter. (New event block — did not exist before the Trigger
-unification.)
+same Step vocabulary as on_enter.
---
diff --git a/building_guide/triggers.md b/building_guide/triggers.md
index ba44c40..d99230b 100644
--- a/building_guide/triggers.md
+++ b/building_guide/triggers.md
@@ -92,8 +92,7 @@ wait elapses.
| `aps_node` | Marks this room's APS node as unlocked. |
| `condition` | Per-step gate (evaluated once when the sequence reaches this step). |
-Messages are **plain strings**, no longer `{message, delay}` maps. The `delay` field is
-gone — use `wait` instead.
+Messages are plain strings. Use `wait` to pause before a step fires.
### Condition vocabulary
@@ -108,7 +107,7 @@ check passes when the flag is **set to a truthy value**. `not: true` inverts any
| `not` | `true` inverts the entire condition. |
| `has_item` | Passes when the player carries this item. |
| `min_credits` | Passes when the player has at least this many credits. |
-| `room` | **New.** Passes when the triggering player is currently in this room. Use it to scope flag-change triggers to a location. |
+| `room` | Passes when the triggering player is currently in this room. Use it to scope flag-change triggers to a location. |
| `all_of` | List of sub-conditions; all must pass. |
| `any_of` | List of sub-conditions; any one must pass. |
@@ -144,10 +143,9 @@ on_use:
crystal_inserted: true
```
-For `on_enter` specifically, the old flat step list ran **every** matching step. Migration
-wraps the old steps into a single Trigger entry, so all the old steps still run as one
-sequence. New `on_enter` content should use multiple entries with distinct conditions when
-the steps are mutually exclusive.
+For `on_enter` specifically, entries are walked top-to-bottom and the first whose
+condition passes runs. Use multiple entries with distinct conditions when the steps
+are mutually exclusive.
### Lock
@@ -181,7 +179,7 @@ entity:
| Object | `on_use`, `on_look` |
| Mob | `on_kill` |
| Exit (room YAML) | `on_traverse` |
-| Room | `on_enter`, `on_exit` (NEW) |
+| Room | `on_enter`, `on_exit` |
| Flag trigger file (`data/triggers/player_flag/`, `data/triggers/global_flag/`) | one Trigger per file (`on_player_flag` / `on_global_flag`) |
- **on_use / on_look** — see `objects.md`. `item_id` filters which item triggers the entry.
@@ -190,7 +188,7 @@ entity:
(not when it's blocked). The exit's own `condition:` / `blocked_message:` belong to the
exit gate, not the Trigger list.
- **on_enter** — fires after the player enters the room.
-- **on_exit** (NEW) — fires when a player leaves the room, **before** `RoomID` is updated
+- **on_exit** — fires when a player leaves the room, **before** `RoomID` is updated
to the destination. Broadcasts/spawn resolve against the old (departed) room. Mirror of
on_enter, useful for parting messages, closing spawns, or recording departures.
- **on_flag_change** / **on_global_flag_change** — flag-watch triggers are **file-backed**, one per file under `data/triggers/player_flag/` or `data/triggers/global_flag/`. They are not room fields. See Flag triggers below.
diff --git a/data/help/casino.yaml b/data/help/casino.yaml
index 4634579..e1ce06d 100644
--- a/data/help/casino.yaml
+++ b/data/help/casino.yaml
@@ -1,7 +1,7 @@
name: casino
aliases: [play, bet, rebet, table, watch, rules]
category: Interaction
-related: [big6, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker]
+related: [big6, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker, plinko]
description: |
Casino quick guide
@@ -18,10 +18,11 @@ description: |
Profit odds exclude your returned stake: a 10-credit win at 2:1
returns 30 total (20 profit + 10 stake). A push returns the stake;
- a loss returns nothing. Slots and video poker paytables use total-return
+ a loss returns nothing. Slots, Plinko and video poker paytables use total-return
multipliers; the shared payout receipt uses profit odds instead.
Fractional table-game returns round down to whole credits, except
- craps, which rounds stakes up to exact payout units (commission rounds down).
+ craps, which rounds stakes up to exact payout units (commission rounds down),
+ and Plinko, which randomly rounds fractional batch returns without bias.
Every game uses the same payout receipt: Wager, Staked, Odds, Result, Net.
All amounts are credits. Returned includes any returned stake; Net is
@@ -43,6 +44,7 @@ description: |
help baccarat Bet which hand finishes closest to 9.
help craps Multiplayer dice, free odds, Fire and ATS bets.
help slots Matching symbols, ways and free spins.
+ help plinko Animated pyramid, four risk levels, up to ten balls.
help video poker Five cabinet types, selectable games, toggle holds and bonuses.
help pai gow poker Seven cards, two hands; Classic, EZ, No Push and Fortune/Envy.
diff --git a/data/help/plinko.yaml b/data/help/plinko.yaml
new file mode 100644
index 0000000..4691cd6
--- /dev/null
+++ b/data/help/plinko.yaml
@@ -0,0 +1,47 @@
+name: plinko
+aliases: [difficulty, balls]
+category: Interaction
+related: [casino]
+description: |
+ PLINKO — let it rain!
+
+ play plinko Take the single seat at a pyramid.
+ difficulty easy Choose easy, medium, hard or expert.
+ balls 5 Drop 1-10 balls together (default 1).
+ paytable Check the current pocket multipliers.
+ bet 10 Wager 10 PER BALL and release them.
+ bet / rebet Repeat with your current risk and ball count.
+ stop Leave after the balls have landed.
+
+ Every ball makes 16 independent, equally likely left/right bounces.
+ The payout legend runs down the left, outside the pyramid, one entry per
+ row, so every value is known well before the ball reaches the pockets.
+ Each entry takes the pocket's board color. The board then unfolds one row
+ per tick, without clearing your screen. Balls fall between pegs.
+ Equal payouts share a letter: normally A B C D E F G H I H G F E D C B A.
+ Rounded ties also share letters, so some risks use fewer than nine labels.
+ A single dim-white ruler of pocket letters prints once, at the board's
+ midpoint, marking the fixed pocket columns; the base repeats it in
+ brackets. Hints mark positions, not a prediction of the result.
+ The outside A pockets pay 16x on easy, 100x on medium, 1000x on hard
+ and 10000x on expert.
+ Multipliers INCLUDE your stake. Each outer pocket has a 1/65536 chance.
+ Higher difficulty has much higher variance. Upward payout rounding also
+ makes the actual expected return differ slightly between difficulties.
+
+ A single ball is white. Overlapping balls become pink, then pure red
+ when all released balls meet. Basic ANSI approximates these shades.
+ Without color, overlaps show 2-9 or * for ten balls.
+
+ The room's configured BASE return defaults to 98% at every difficulty.
+ Interior multipliers adjust to that base, then round UP to the next 0.1x;
+ the outside prizes stay fixed. The displayed multipliers are what pays.
+ Rounding increases actual RTP (possibly above 100%); the menu shows the
+ actual return for your selected difficulty as well as the configured base.
+ Fractional batch winnings round randomly to adjacent whole credits,
+ preserving the expected return even for one-credit bets.
+
+ Your full batch is charged up front and paid once when all balls land.
+ You cannot change settings or leave mid-drop. Disconnect forfeits an
+ unfinished drop; shutdown refunds pending stakes. Failed payments keep
+ their stake pending for recovery rather than claiming a paid win.
diff --git a/data/rooms/casino/6199.yaml b/data/rooms/casino/6199.yaml
index e491ad2..5c5faff 100644
--- a/data/rooms/casino/6199.yaml
+++ b/data/rooms/casino/6199.yaml
@@ -1,6 +1,8 @@
name: Sword and Shield Wheel Salon
-description: A towering Big 6 wheel gleams with brass spokes and painted banknotes. A sword and a shield mark its rarest prizes; a golden DOUBLE flashes between them. Broad green felt offers room for a handful of wagers. The double-zero roulette salon lies south.
+description: A towering Big 6 wheel gleams with brass spokes and painted banknotes. A sword and a shield mark its rarest prizes; a golden DOUBLE flashes between them. Broad green felt offers room for a handful of wagers. A luminous Plinko pyramid glows through an archway to the north. The double-zero roulette salon lies south.
exits:
+ north:
+ room: 6200
south:
room: 6183
casino_tables:
diff --git a/data/rooms/casino/6200.yaml b/data/rooms/casino/6200.yaml
new file mode 100644
index 0000000..0e35a17
--- /dev/null
+++ b/data/rooms/casino/6200.yaml
@@ -0,0 +1,12 @@
+name: Crimson Rain Plinko Salon
+description: A luminous pyramid rises above a row of jewel-bright prize pockets. Polished pins scatter falling balls into a rain of white and crimson sparks, while enormous edge jackpots burn like miniature suns. A brass plaque invites you to choose your risk and let it rain. The Sword and Shield wheel salon lies south.
+exits:
+ south:
+ room: 6199
+casino_tables:
+ - id: plinko
+ game: plinko
+ min_bet: 1
+ max_bet: 10000
+ plinko:
+ rtp: 98
diff --git a/internal/casino/house_edge.go b/internal/casino/house_edge.go
index 01fe7aa..e175e24 100644
--- a/internal/casino/house_edge.go
+++ b/internal/casino/house_edge.go
@@ -59,6 +59,13 @@ func (c TableConfig) HouseEdge(bet string) HouseEdge {
// HouseEdge reads seat-specific settings under the game's lock. It does not
// alter game state, advance RNGs, or reveal cards from the current shoe.
func (t *Table) HouseEdge(name, bet string) HouseEdge {
+ if p, ok := t.game.(*plinkoTable); ok {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.name == name {
+ return edge(1 - plinkoReturn(p.risk, p.Config.Plinko.Normalize().RTP))
+ }
+ }
if vp, ok := t.game.(*videoPokerTable); ok {
vp.mu.Lock()
defer vp.mu.Unlock()
diff --git a/internal/casino/plinko.go b/internal/casino/plinko.go
new file mode 100644
index 0000000..d73a0a4
--- /dev/null
+++ b/internal/casino/plinko.go
@@ -0,0 +1,395 @@
+package casino
+
+import (
+ "fmt"
+ "math"
+ "math/rand"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+const plinkoRows = 16
+
+const plinkoFirstRow = 0
+
+// RTP is the base percentage before pocket multipliers round up to tenths.
+// Fixed expert edge prizes alone return 30.517578125%.
+type PlinkoConfig struct {
+ RTP float64 `yaml:"rtp,omitempty"`
+}
+
+func (c PlinkoConfig) Normalize() PlinkoConfig {
+ if c.RTP == 0 {
+ c.RTP = 98
+ }
+ return c
+}
+func validatePlinko(c TableConfig) error {
+ r := c.Plinko.Normalize().RTP
+ if math.IsNaN(r) || math.IsInf(r, 0) || r < 31 || r > 100 {
+ return fmt.Errorf("plinko rtp must be between 31 and 100 percent")
+ }
+ if c.MaxPlayers > 1 {
+ return fmt.Errorf("plinko has one seat")
+ }
+ if c.MaxBet > MaxCredits/100000 {
+ return fmt.Errorf("plinko max_bet must not exceed %d", MaxCredits/100000)
+ }
+ return nil
+}
+
+var plinkoRisks = []string{"easy", "medium", "hard", "expert"}
+
+// Every path has probability 2^-16. Normalize interior weights against the
+// binomial distribution, keeping both edge jackpots fixed at every RTP.
+// Round the played multipliers UP to tenths, intentionally raising actual RTP.
+func plinkoPockets(risk int, rtp float64) [17]float64 {
+ var out [17]float64
+ outer := []float64{16, 100, 1000, 10000}[risk]
+ powers := []float64{2, 4, 7, 12}
+ out[0], out[16] = outer, outer
+ sum, choose := 0.0, 1.0
+ for k := 1; k < 16; k++ {
+ choose *= float64(17-k) / float64(k)
+ out[k] = math.Pow(float64(absPlinko(k-8)+1), powers[risk])
+ sum += choose * out[k] / 65536
+ }
+ scale := (rtp/100 - 2*outer/65536) / sum
+ for k := 1; k < 16; k++ {
+ out[k] = math.Ceil(out[k]*scale*10) / 10
+ }
+ return out
+}
+func absPlinko(n int) int {
+ if n < 0 {
+ return -n
+ }
+ return n
+}
+func plinkoReturn(risk int, rtp float64) float64 {
+ pockets := plinkoPockets(risk, rtp)
+ choose, result := 1.0, 0.0
+ for k, multiplier := range pockets {
+ if k > 0 {
+ choose *= float64(17-k) / float64(k)
+ }
+ result += choose * multiplier / 65536
+ }
+ return result
+}
+
+func plinkoHouseEdge(c TableConfig, _ string) HouseEdge {
+ var e HouseEdge
+ for risk, name := range plinkoRisks {
+ e.Parts = append(e.Parts, EdgePart{Label: name, Loss: 1 - plinkoReturn(risk, c.Plinko.Normalize().RTP)})
+ }
+ e.Note = "easy / medium / hard / expert; includes upward rounding to 0.1x"
+ return e
+}
+
+func plinkoRules(c TableConfig) string {
+ return fmt.Sprintf("Plinko: 16 fair left/right bounces; 17 pockets. Base RTP %.4g%%. Multipliers round UP to 0.1x, increasing actual RTP by risk.\nUse difficulty easy|medium|hard|expert, balls 1-10, then bet N (per ball).\nPocket multipliers INCLUDE your stake. Fractional total returns round randomly, without bias.\nEasy edges pay 16x; expert edges pay 10000x. Higher risk means higher variance.\nOne ball is white; overlapping balls shade toward red (all balls together).\nLeaving is blocked during a drop; disconnect forfeits an unfinished drop.", c.Plinko.Normalize().RTP)
+}
+
+type PlinkoSnapshot struct {
+ // Row 0 is the apex (preceded by the payout legend); 16 is the pockets.
+ Row, Total int
+ Counts [17]int
+ Pockets [17]float64
+ Paytable bool
+ Risk string
+}
+
+func (*PlinkoSnapshot) snapshot() {}
+
+// PlinkoPocketLabels gives every distinct played multiplier one letter, in
+// outside-to-center order. Mirrored pockets and rounded ties share a label.
+// Settlement, the legend and the board must use the same mapping.
+func PlinkoPocketLabels(pockets [17]float64) [17]rune {
+ var labels [17]rune
+ seen := make(map[float64]rune)
+ for k, value := range pockets {
+ label, ok := seen[value]
+ if !ok {
+ label = 'A' + rune(len(seen))
+ seen[value] = label
+ }
+ labels[k] = label
+ }
+ return labels
+}
+
+type plinkoTable struct {
+ *tableBase
+ mu sync.Mutex
+ rng *rand.Rand
+ name string
+ risk, balls, betSize, row int
+ active bool
+ positions []int
+ wallet Wallet
+ stake Wager
+}
+
+func newPlinkoTable(room int, c TableConfig, rng *rand.Rand) tableGame {
+ return &plinkoTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, balls: 1}
+}
+func (t *plinkoTable) base() *tableBase { return t.tableBase }
+func (t *plinkoTable) idle() bool { t.mu.Lock(); defer t.mu.Unlock(); return t.name == "" }
+func (t *plinkoTable) hasParticipant(n string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.name != "" && t.name == n
+}
+func (t *plinkoTable) busy(n string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.name == n && t.active
+}
+func (t *plinkoTable) overview() string { return "Plinko / 16-row pyramid" }
+func (t *plinkoTable) seats() []string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.name == "" {
+ return nil
+ }
+ return []string{t.name}
+}
+func (t *plinkoTable) state() string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.active {
+ return "dropping"
+ }
+ return "waiting"
+}
+func (t *plinkoTable) wagers(string) []WagerRow { return nil }
+func (t *plinkoTable) hintsLocked() []MenuCommand {
+ if t.active {
+ return nil
+ }
+ return []MenuCommand{{Command: "bet N", Desc: "Drop balls; N credits PER ball"}, {Command: "balls N", Desc: "Drop 1-10 balls together"}, {Command: "difficulty easy|medium|hard|expert", Desc: "Choose payout variance"}, {Command: "paytable", Desc: "Show all pocket multipliers"}, {Command: "rules", Desc: "Plinko rules"}, {Command: "stop", Desc: "Leave Plinko"}}
+}
+func (t *plinkoTable) hints(n string) []MenuCommand {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n != t.name {
+ return nil
+ }
+ return t.hintsLocked()
+}
+func (t *plinkoTable) snap(paytable bool) *PlinkoSnapshot {
+ s := &PlinkoSnapshot{Row: t.row, Total: t.balls, Pockets: plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP), Risk: plinkoRisks[t.risk], Paytable: paytable}
+ for _, p := range t.positions {
+ s.Counts[p]++
+ }
+ return s
+}
+func (t *plinkoTable) statusLocked(n string) []Event {
+ e := t.private(n, fmt.Sprintf("PLINKO / %s / %d balls / %.2f%% RTP (base %.4g%%)", plinkoRisks[t.risk], t.balls, 100*plinkoReturn(t.risk, t.Config.Plinko.Normalize().RTP), t.Config.Plinko.Normalize().RTP))
+ e.Color = "casino_menu"
+ e.Snapshot = t.snap(true)
+ if n == t.name {
+ e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()}
+ }
+ return []Event{e}
+}
+func (t *plinkoTable) status(n string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.statusLocked(n)
+}
+func (t *plinkoTable) board(string) Snapshot { t.mu.Lock(); defer t.mu.Unlock(); return t.snap(true) }
+func (t *plinkoTable) join(n string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.name != "" {
+ return []Event{t.private(n, "Plinko is occupied.")}
+ }
+ t.name = n
+ t.risk = 0
+ t.balls = 1
+ e := t.playerAction(n, n+" steps up to Plinko.", "Welcome to PLINKO! Choose your risk and let it rain.")
+ e.Type = EventJoined
+ e.Player = n
+ return append([]Event{e}, t.statusLocked(n)...)
+}
+func (t *plinkoTable) clear() {
+ t.name = ""
+ t.active = false
+ t.positions = nil
+ t.wallet = nil
+ t.stake = Wager{}
+ t.betSize = 0
+ t.row = 0
+}
+func (t *plinkoTable) leave(n string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n != t.name {
+ return nil
+ }
+ if t.active {
+ return []Event{t.private(n, "The balls are still falling!")}
+ }
+ t.clear()
+ e := t.playerAction(n, n+" leaves Plinko.", "You leave Plinko.")
+ e.Type = EventLeft
+ e.Player = n
+ return []Event{e}
+}
+func (t *plinkoTable) rebet(n string) (string, int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n == t.name {
+ return "", t.betSize
+ }
+ return "", 0
+}
+func (t *plinkoTable) action(n, a string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n != t.name {
+ return nil
+ }
+ if t.active {
+ return []Event{t.private(n, "Wait for the balls to land.")}
+ }
+ f := strings.Fields(strings.ToLower(a))
+ if len(f) == 1 && f[0] == "paytable" {
+ return t.statusLocked(n)
+ }
+ if len(f) == 2 {
+ switch f[0] {
+ case "balls":
+ v, err := strconv.Atoi(f[1])
+ if err == nil && v >= 1 && v <= 10 {
+ t.balls = v
+ return t.statusLocked(n)
+ }
+ case "difficulty":
+ for i, r := range plinkoRisks {
+ if f[1] == r {
+ t.risk = i
+ return t.statusLocked(n)
+ }
+ }
+ }
+ }
+ return []Event{t.private(n, "Use difficulty easy|medium|hard|expert, balls 1-10, or paytable.")}
+}
+func (t *plinkoTable) bet(n, spot string, amount int, w Wallet) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n != t.name {
+ return nil
+ }
+ if t.active {
+ return []Event{t.private(n, "The balls are still falling!")}
+ }
+ if spot != "" && spot != "main" {
+ return []Event{t.private(n, "Use bet N; N is the stake per ball.")}
+ }
+ if amount < t.Config.MinBet || amount > t.Config.MaxBet || amount > MaxCredits/100000 {
+ return []Event{t.private(n, "Per-ball bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")}
+ }
+ if w == nil {
+ return []Event{t.private(n, "No wallet is available.")}
+ }
+ stake, err := w.Wager(amount * t.balls)
+ if err != nil {
+ return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits for all balls."))}
+ }
+ t.wallet = w
+ t.stake = stake
+ t.betSize = amount
+ t.row = plinkoFirstRow
+ t.active = true
+ t.positions = make([]int, t.balls)
+ e := t.private(n, fmt.Sprintf("*** PLINKO / %s *** %d balls x %d = %d credits. Let it rain!", strings.ToUpper(plinkoRisks[t.risk]), t.balls, amount, stake.Total))
+ e.Snapshot = t.snap(false)
+ return []Event{e}
+}
+func (t *plinkoTable) tick() []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if !t.active {
+ return nil
+ }
+ t.row++
+ if t.row > 0 {
+ for i := range t.positions {
+ t.positions[i] += t.rng.Intn(2)
+ }
+ }
+ e := t.private(t.name, "")
+ e.Snapshot = t.snap(false)
+ events := []Event{e}
+ if t.row < plinkoRows {
+ return events
+ }
+ t.active = false
+ pockets := plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP)
+ labels := PlinkoPocketLabels(pockets)
+ total := 0.0
+ var hits []string
+ for i, p := range t.positions {
+ v := pockets[p] * float64(t.betSize)
+ total += v
+ hits = append(hits, fmt.Sprintf("Ball %d -> [%c] %.1fx", i+1, labels[p], pockets[p]))
+ }
+ paid := int(math.Floor(total))
+ if t.rng.Float64() < total-float64(paid) {
+ paid++
+ }
+ if err := t.wallet.PayCredits(paid, t.stake.ID); err != nil {
+ return append(events, t.private(t.name, payoutFailedMessage()))
+ }
+ t.stake = Wager{}
+ result := t.private(t.name, "")
+ outcome := "loss"
+ if paid == t.betSize*t.balls {
+ outcome = "push"
+ } else if paid > t.betSize*t.balls {
+ outcome = "win"
+ }
+ result.Settlement = []SettlementLine{{Label: fmt.Sprintf("%d Plinko balls", t.balls), Odds: "Varies", Stake: t.betSize * t.balls, Returned: paid, Result: outcome}}
+ result.Color = "casino_payout"
+ if paid > t.betSize*t.balls {
+ result.Color = "casino_big_win"
+ }
+ // The shared receipt replaces Message, so keep pocket reveals in their own event.
+ events = append(events, t.private(t.name, strings.Join(hits, "\n")), result, t.betMenuEvent(t.name, t.hintsLocked()))
+ return events
+}
+func (t *plinkoTable) markDisconnected(n string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if n != t.name {
+ return nil
+ }
+ if t.active && t.wallet != nil {
+ t.wallet.ResolveStake(t.stake.ID)
+ }
+ t.clear()
+ e := t.event(n+" leaves Plinko.", true)
+ e.Type = EventLeft
+ e.Player = n
+ return []Event{e}
+}
+func (t *plinkoTable) refundAll() []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ var events []Event
+ if t.wallet != nil {
+ chips, credits, err := t.wallet.RefundOutstanding()
+ msg := fmt.Sprintf("Plinko closes; %d credits/chips refunded.", chips+credits)
+ if err != nil {
+ msg = "Plinko refund could not be persisted; your stake remains pending for recovery."
+ }
+ events = append(events, t.private(t.name, msg))
+ }
+ t.clear()
+ return events
+}
diff --git a/internal/casino/plinko_test.go b/internal/casino/plinko_test.go
new file mode 100644
index 0000000..2b79853
--- /dev/null
+++ b/internal/casino/plinko_test.go
@@ -0,0 +1,218 @@
+package casino
+
+import (
+ "fmt"
+ "math"
+ "math/rand"
+ "strings"
+ "testing"
+)
+
+func TestPlinkoRoundedRTPAndVariance(t *testing.T) {
+ for _, rtp := range []float64{31, 90, 98, 100} {
+ lastVariance := -1.0
+ for risk := 0; risk < 4; risk++ {
+ p := plinkoPockets(risk, rtp)
+ choose, mean, second := 1.0, 0.0, 0.0
+ for k, v := range p {
+ if k > 0 {
+ choose *= float64(17-k) / float64(k)
+ }
+ mean += choose * v / 65536
+ second += choose * v * v / 65536
+ if v < 0 || v != p[16-k] {
+ t.Fatalf("invalid symmetric pockets %v", p)
+ }
+ if k < 8 && v < p[k+1] {
+ t.Fatalf("pockets must grow toward edges: %v", p)
+ }
+ }
+ if mean < rtp/100 || mean >= rtp/100+0.1 {
+ t.Fatalf("risk %d rounded RTP %g not within [base, base+0.1)", risk, mean)
+ }
+ if math.Abs(mean-plinkoReturn(risk, rtp)) > 1e-12 {
+ t.Fatal("reported RTP differs from played pockets")
+ }
+ for _, v := range p {
+ if math.Abs(v*10-math.Round(v*10)) > 1e-9 {
+ t.Fatalf("not a tenth: %g", v)
+ }
+ }
+ variance := second - mean*mean
+ if variance <= lastVariance {
+ t.Fatalf("variance did not rise: %g <= %g", variance, lastVariance)
+ }
+ lastVariance = variance
+ if risk == 0 && p[0] != 16 || risk == 3 && p[0] != 10000 {
+ t.Fatal("wrong jackpot")
+ }
+ }
+ }
+}
+func newTestPlinko() *plinkoTable {
+ return newTable(1, TableConfig{ID: "plinko", Game: GamePlinko}, rand.New(rand.NewSource(42))).game.(*plinkoTable)
+}
+func TestPlinkoDropLifecycle(t *testing.T) {
+ p := newTestPlinko()
+ p.join("alice")
+ p.action("alice", "balls 10")
+ p.action("alice", "difficulty expert")
+ w := &testWallet{credits: 1000}
+ p.bet("alice", "", 10, w)
+ if len(w.open) != 1 || w.open[0].Total != 100 {
+ t.Fatal("batch must wager once")
+ }
+ p.bet("alice", "", 10, w)
+ p.action("alice", "balls 2")
+ p.leave("alice")
+ if !p.active || p.balls != 10 || len(w.open) != 1 {
+ t.Fatal("active drop changed")
+ }
+ for row := plinkoFirstRow + 1; row <= 16; row++ {
+ old := append([]int(nil), p.positions...)
+ events := p.tick()
+ s := events[0].Snapshot.(*PlinkoSnapshot)
+ sum := 0
+ for _, n := range s.Counts {
+ sum += n
+ }
+ if s.Row != row || sum != 10 {
+ t.Fatal("wrong animation")
+ }
+ for i, pos := range p.positions {
+ if row <= 0 && pos != old[i] {
+ t.Fatal("ball moved during label prelude")
+ }
+ if pos-old[i] < 0 || pos-old[i] > 1 {
+ t.Fatal("not a left/right bounce")
+ }
+ }
+ }
+ if p.active || len(w.open) != 0 {
+ t.Fatal("unsettled drop")
+ }
+ paid := w.paid
+ p.tick()
+ p.refundAll()
+ if w.paid != paid {
+ t.Fatal("duplicate payout")
+ }
+}
+func TestPlinkoRefundAndDisconnect(t *testing.T) {
+ for _, disconnect := range []bool{false, true} {
+ p := newTestPlinko()
+ p.join("alice")
+ w := &testWallet{credits: 100}
+ p.bet("alice", "", 10, w)
+ p.tick()
+ if disconnect {
+ p.markDisconnected("alice")
+ } else {
+ p.refundAll()
+ }
+ if len(w.open) != 0 || !p.idle() {
+ t.Fatal("stake or seat left open")
+ }
+ if !disconnect && w.credits+w.paid != 100 {
+ t.Fatal("shutdown failed to refund")
+ }
+ if disconnect && w.credits != 90 {
+ t.Fatal("disconnect must forfeit")
+ }
+ if len(p.tick()) != 0 {
+ t.Fatal("discarded drop continued")
+ }
+ }
+}
+func TestPlinkoRoundedPocketsArePaidAndAnalyzed(t *testing.T) {
+ p := newTestPlinko()
+ p.join("alice")
+ p.action("alice", "difficulty expert")
+ w := &testWallet{credits: 100}
+ p.bet("alice", "", 10, w)
+ // Both possible final center pockets round up to 0.1x, not just in the UI.
+ p.row, p.positions = 15, []int{7}
+ events := p.tick()
+ if w.paid != 1 || len(w.open) != 0 {
+ t.Fatalf("rounded pocket not paid: %+v", w)
+ }
+ for _, e := range events {
+ if strings.Contains(e.Message, "credits returned") {
+ t.Fatal("duplicate payout prose")
+ }
+ }
+ table := &Table{tableBase: p.tableBase, game: p}
+ want := 1 - plinkoReturn(3, 98)
+ if got := table.HouseEdge("alice", "main"); len(got.Parts) != 1 || math.Abs(got.Parts[0].Loss-want) > 1e-12 {
+ t.Fatalf("seat edge ignores rounded payouts: %+v", got)
+ }
+ if got := p.Config.HouseEdge("main"); len(got.Parts) != 4 || math.Abs(got.Parts[3].Loss-want) > 1e-12 {
+ t.Fatalf("config edge ignores risk: %+v", got)
+ }
+}
+
+func TestPlinkoSharedPocketLabels(t *testing.T) {
+ for risk := 0; risk < 4; risk++ {
+ pockets := plinkoPockets(risk, 98)
+ labels := PlinkoPocketLabels(pockets)
+ for i := range pockets {
+ for j := range pockets {
+ if (pockets[i] == pockets[j]) != (labels[i] == labels[j]) {
+ t.Fatalf("risk %d: payout/label mismatch at %d,%d", risk, i, j)
+ }
+ }
+ }
+ if labels[0] != 'A' || labels[16] != 'A' {
+ t.Fatal("both edges must be A")
+ }
+ }
+ p := newTestPlinko()
+ p.join("alice")
+ w := &testWallet{credits: 100}
+ p.bet("alice", "", 1, w)
+ p.row, p.positions = 15, []int{15}
+ events := p.tick()
+ labels := PlinkoPocketLabels(plinkoPockets(0, 98))
+ want := fmt.Sprintf("Ball 1 -> [%c]", labels[p.positions[0]])
+ found := false
+ for _, e := range events {
+ found = found || strings.Contains(e.Message, want)
+ }
+ if !found {
+ t.Fatalf("settlement did not use mirrored label %s", want)
+ }
+}
+
+func TestPlinkoConfig(t *testing.T) {
+ for _, rtp := range []float64{-1, 30, 101, math.NaN(), math.Inf(1)} {
+ if (TableConfig{ID: "p", Game: GamePlinko, Plinko: PlinkoConfig{RTP: rtp}}).Validate() == nil {
+ t.Fatalf("accepted RTP %g", rtp)
+ }
+ }
+ c := TableConfig{ID: "p", Game: GamePlinko}
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if c.Normalize().Plinko.RTP != 98 {
+ t.Fatal("wrong default")
+ }
+ if (TableConfig{ID: "p", Game: GameRoulette, Plinko: PlinkoConfig{RTP: 98}}).Validate() == nil {
+ t.Fatal("wrong rule block accepted")
+ }
+}
+func TestPlinkoFailedPayoutRetainsStake(t *testing.T) {
+ p := newTestPlinko()
+ p.join("alice")
+ w := &failingPayoutWallet{testWallet: testWallet{credits: 100}}
+ p.bet("alice", "", 10, w)
+ for i := plinkoFirstRow; i < 16; i++ {
+ p.tick()
+ }
+ if len(w.open) != 1 {
+ t.Fatal("failed settlement consumed stake")
+ }
+ p.leave("alice")
+ if len(w.open) != 1 {
+ t.Fatal("leave consumed failed stake")
+ }
+}
diff --git a/internal/casino/registry.go b/internal/casino/registry.go
index 4366bc6..fdaecec 100644
--- a/internal/casino/registry.go
+++ b/internal/casino/registry.go
@@ -44,6 +44,16 @@ var tableGames map[GameName]tableGameDef
func init() {
tableGames = map[GameName]tableGameDef{
+ GamePlinko: {
+ validate: validatePlinko,
+ normalize: func(c TableConfig) TableConfig { c.Plinko = c.Plinko.Normalize(); c.MaxPlayers = 1; return c },
+ ruleBlockName: "plinko",
+ hasRuleBlock: func(c TableConfig) bool { return c.Plinko != (PlinkoConfig{}) },
+ new: newPlinkoTable,
+ ruleText: plinkoRules,
+ helpTopic: func(TableConfig) string { return "plinko" },
+ houseEdge: plinkoHouseEdge,
+ },
GamePaiGow: {
houseEdge: paiGowHouseEdge,
validate: validatePaiGow,
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 8986dc1..5f4d4f8 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -17,6 +17,7 @@ const (
GamePaiGow GameName = "paigow"
GameVideoPoker GameName = "video_poker"
GameSlots GameName = "slots"
+ GamePlinko GameName = "plinko"
GameBlackjack GameName = "blackjack"
GameBaccarat GameName = "baccarat"
GameRoulette GameName = "roulette"
@@ -124,6 +125,7 @@ func (c BaccaratConfig) Normalize() BaccaratConfig {
}
type TableConfig struct {
+ Plinko PlinkoConfig `yaml:"plinko,omitempty"`
ID string `yaml:"id"`
Game GameName `yaml:"game"`
MaxPlayers int `yaml:"max_players,omitempty"`
diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go
index 9e36466..f163fb8 100644
--- a/internal/game/blackjack_rules.go
+++ b/internal/game/blackjack_rules.go
@@ -30,6 +30,10 @@ func (g *Game) lookRouletteTable(sess *net.Session, target string) bool {
return g.lookCasinoTable(sess, target, casino.GameRoulette)
}
+func (g *Game) lookPlinkoTable(sess *net.Session, target string) bool {
+ return g.lookCasinoTable(sess, target, casino.GamePlinko)
+}
+
func (g *Game) lookPaiGowTable(sess *net.Session, target string) bool {
return g.lookCasinoTable(sess, target, casino.GamePaiGow)
}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 6dce0bd..15e3b6a 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -707,14 +707,15 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas
}
_, isCraps := snapshot.(*casino.CrapsSnapshot)
_, isPoker := snapshot.(*casino.VideoPokerSnapshot)
+ _, isPlinko := snapshot.(*casino.PlinkoSnapshot)
_, isPaiGow := snapshot.(*casino.PaiGowSnapshot)
b, isBigSix := snapshot.(*casino.BigSixSnapshot)
isBigSix = isBigSix && b.Phase != "playing" && b.Phase != "result"
s, isRoulette := snapshot.(*casino.RouletteSnapshot)
- if !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
+ if !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
return "", false
}
- if !isPoker && !isCraps && !isPaiGow && !isBigSix && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
+ if !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
// The compact reminder is already a short block; leave it intact.
return "", false
}
@@ -741,6 +742,9 @@ func composeTableThenReminder(table, reminder string) string {
// renderCasinoMessage colorizes a casino message unless it is an
// already-composed structured menu.
func (g *Game) renderCasinoMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string {
+ if message == "" {
+ return ""
+ }
if category == "casino_menu" && menu != nil {
return message
}
@@ -797,6 +801,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot)
// how verbose the command menus are.
size := sess.Player.OptionInt("card_size")
switch s := snapshot.(type) {
+ case *casino.PlinkoSnapshot:
+ return renderPlinko(s, mode, unicode)
case *casino.PaiGowSnapshot:
return renderPaiGow(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt)
case *casino.VideoPokerSnapshot:
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index 5b60118..d8cc18f 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -38,6 +38,12 @@ var commandRegistry = map[string]commandDef{
}
g.executeTableAction(s, "deal")
}, ClassActive},
+ "difficulty": {func(g *Game, s *net.Session, args []string, _ string) {
+ g.executeTableAction(s, "difficulty "+strings.Join(args, " "))
+ }, ClassInstant},
+ "balls": {func(g *Game, s *net.Session, args []string, _ string) {
+ g.executeTableAction(s, "balls "+strings.Join(args, " "))
+ }, ClassInstant},
"paytable": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "paytable") }, ClassInstant},
"denom": {func(g *Game, s *net.Session, args []string, _ string) {
g.executeTableAction(s, "denom "+strings.Join(args, " "))
@@ -249,6 +255,7 @@ var casinoCommonVerbs = []string{"watch", "game", "table", "list", "stop", "rule
// command: "sp" is split at a blackjack table, but spin at a Big 6 wheel,
// because "split" is not a Big 6 verb and "spin" is not a blackjack verb.
var casinoVerbsByGame = map[casino.GameName][]string{
+ casino.GamePlinko: {"bet", "rebet", "difficulty", "balls", "paytable"},
casino.GameBlackjack: {"bet", "rebet", "unbet", "hit", "stand", "double", "split", "insurance", "surrender", "decline"},
casino.GameBaccarat: {"bet", "rebet", "unbet"},
casino.GameRoulette: {"bet", "rebet", "unbet"},
diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go
index ec9ab61..df6a714 100644
--- a/internal/game/look_casino.go
+++ b/internal/game/look_casino.go
@@ -83,7 +83,7 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi
fmt.Sprintf(" Seated %s", seated),
}
switch cfg.Game {
- case casino.GameRoulette, casino.GameBigSix:
+ case casino.GameRoulette, casino.GameBigSix, casino.GamePlinko:
// Look, like sitting down and the table command, shows the live
// wheel: tracker, racetrack, and betting layout.
if rendered := strings.TrimRight(g.renderCasinoSnapshot(sess, table.Board(sess.Player.Name)), "\n"); rendered != "" {
@@ -103,6 +103,8 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi
// look details block. The full wording lives in the rules help page.
func casinoTableSummary(cfg casino.TableConfig) string {
switch cfg.Game {
+ case casino.GamePlinko:
+ return fmt.Sprintf("16 rows; four risks; 1-10 balls; %.4g%% base RTP + upward rounding", cfg.Plinko.Normalize().RTP)
case casino.GameBlackjack:
b := cfg.Blackjack.Normalize()
dealer := "dealer stands on soft 17"
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 9065ccc..cfa6009 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -47,7 +47,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- if g.lookBigSixTable(sess, lower) || g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookPaiGowTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) {
+ if g.lookPlinkoTable(sess, lower) || g.lookBigSixTable(sess, lower) || g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookPaiGowTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) {
return
}
diff --git a/internal/game/plinko_test.go b/internal/game/plinko_test.go
new file mode 100644
index 0000000..711525f
--- /dev/null
+++ b/internal/game/plinko_test.go
@@ -0,0 +1,261 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "unicode/utf8"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+func TestPlinkoCommands(t *testing.T) {
+ g, s, conn := casinoCrapsGame(t, "name: Plinko\ncasino_tables:\n - id: plinko\n game: plinko\n")
+ g.executePlay(s, []string{"plinko"}, "play plinko")
+ g.executeCommand(s, "difficulty", []string{"expert"}, "difficulty expert")
+ g.executeCommand(s, "balls", []string{"10"}, "balls 10")
+ table, ok := g.Casino.Participation(s.Player.Name)
+ if !ok || table == nil {
+ t.Fatal("not seated")
+ }
+ snap := table.Status(s.Player.Name)[0].Snapshot.(*casino.PlinkoSnapshot)
+ if snap.Total != 10 || snap.Risk != "expert" || snap.Pockets[0] != 10000 {
+ t.Fatalf("commands failed: %+v", snap)
+ }
+ g.executeBet(s, []string{"1"}, "bet 1")
+ if !table.Busy(s.Player.Name) || len(s.Player.PendingWagers) != 1 {
+ t.Fatal("drop did not start")
+ }
+ conn.data = nil
+ for i := 0; i < 32 && table.Busy(s.Player.Name); i++ {
+ g.emitCasinoEvents(g.Casino.Tick())
+ }
+ if table.Busy(s.Player.Name) || len(s.Player.PendingWagers) != 0 {
+ t.Fatal("drop did not settle")
+ }
+ text := stripANSI(string(conn.data))
+ if !strings.Contains(text, "Ball 10 ->") || strings.Count(text, "Returned:") != 1 || strings.Contains(text, "credits returned") || strings.Contains(text, "net +") {
+ t.Fatal(text)
+ }
+ conn.data = nil
+ g.doLookTarget(s, "plinko")
+ if !strings.Contains(stripANSI(string(conn.data)), "P L I N K O") {
+ t.Fatal("look missing paytable")
+ }
+}
+func TestPlinkoBallShades(t *testing.T) {
+ if plinkoBallColor(1, 1) != color.NearestXterm256(255, 255, 255) {
+ t.Fatal("single must be white")
+ }
+ for total := 2; total <= 10; total++ {
+ last := 256
+ for count := 1; count <= total; count++ {
+ r, g, b := color.Xterm256ToRGB(plinkoBallColor(count, total))
+ if r != 255 || g != b || g > last {
+ t.Fatal("invalid white-red gradient")
+ }
+ last = g
+ }
+ if last != 0 {
+ t.Fatal("all balls must be pure red")
+ }
+ }
+}
+func TestPlinkoRenderRows(t *testing.T) {
+ for _, mode := range []string{"none", "ansi", "xterm256"} {
+ for _, unicode := range []bool{false, true} {
+ for row := 0; row <= 16; row++ {
+ s := &casino.PlinkoSnapshot{Row: row, Total: 10}
+ s.Counts[0] = 10
+ out := renderPlinko(s, mode, unicode)
+ for _, line := range strings.Split(stripANSI(out), "\n") {
+ if utf8.RuneCountInString(line) != plinkoWidth {
+ t.Fatal("board must keep its full width, including exterior spaces")
+ }
+ }
+ wantLines := 1
+ if row == plinkoRows/2 || row == plinkoRows {
+ wantLines = 2 // midpoint hint ruler, base rail
+ }
+ if got := len(strings.Split(out, "\n")); got != wantLines {
+ t.Fatalf("row %d printed %d lines, want %d", row, got, wantLines)
+ }
+ if strings.Contains(out, "\x1b[48;") {
+ t.Fatal("the board must not paint a background")
+ }
+ if mode == "none" && (!strings.Contains(out, "*") || strings.Contains(out, "\x1b")) {
+ t.Fatal("plain overlap fallback missing")
+ }
+ }
+ }
+ }
+}
+
+func TestPlinkoUsesSharedHints(t *testing.T) {
+ for _, mode := range []string{"on", "once", "off"} {
+ t.Run(mode, func(t *testing.T) {
+ g, s, conn := casinoCrapsGame(t, "name: Plinko\ncasino_tables:\n - id: plinko\n game: plinko\n")
+ s.Player.Options = map[string]any{"color": "none", "unicode": true, "casino_hints": mode}
+ g.executePlay(s, []string{"plinko"}, "play plinko")
+ text := string(conn.data)
+ if strings.Contains(text, "difficulty easy|medium|hard|expert") != (mode != "off") {
+ t.Fatalf("join hints (%s): %s", mode, text)
+ }
+ if mode != "off" && strings.Index(text, "P L I N K O") > strings.Index(text, "bet N") {
+ t.Fatal("hints must follow the paytable")
+ }
+ conn.data = nil
+ g.executeTableAction(s, "balls 2")
+ if strings.Contains(string(conn.data), "bet N") != (mode == "on") {
+ t.Fatalf("settings hints (%s): %s", mode, conn.data)
+ }
+ g.executeBet(s, []string{"1"}, "bet 1")
+ table, _ := g.Casino.Participation(s.Player.Name)
+ for i := 0; i < 32 && table.Busy(s.Player.Name); i++ {
+ conn.data = nil
+ g.emitCasinoEvents(g.Casino.Tick())
+ }
+ if strings.Contains(string(conn.data), "bet N") != (mode == "on") {
+ t.Fatalf("settlement hints (%s): %s", mode, conn.data)
+ }
+ conn.data = nil
+ g.executeVerbs(s, nil, "what")
+ if !strings.Contains(string(conn.data), "balls N") {
+ t.Fatal("explicit hints missing")
+ }
+ })
+ }
+}
+
+func plinkoTestBoardRow(s *casino.PlinkoSnapshot) []rune {
+ lines := strings.Split(renderPlinko(s, "none", true), "\n")
+ return []rune(lines[0])
+}
+
+func TestPlinkoPegsNeverMove(t *testing.T) {
+ for row := 0; row <= 16; row++ {
+ s := &casino.PlinkoSnapshot{Row: row, Total: 1}
+ empty := plinkoTestBoardRow(s)
+ left, right := 32-2*row, 36+2*row
+ pegs := map[int]bool{}
+ for k := 1; k <= row; k++ {
+ pegs[left+4*k] = true
+ if empty[left+4*k] != '·' {
+ t.Fatalf("row %d: peg missing at its true position %d", row, left+4*k)
+ }
+ }
+ if right < plinkoWidth && empty[right] != '\\' || empty[left] != '/' {
+ t.Fatalf("row %d: border moved", row)
+ }
+ for lane := 0; lane <= row; lane++ {
+ s.Counts = [17]int{}
+ s.Counts[lane] = 1
+ filled := plinkoTestBoardRow(s)
+ x := 34 - 2*row + 4*lane
+ if filled[x] != '●' {
+ t.Fatalf("ball not in its own lane: row %d lane %d", row, lane)
+ }
+ for px := range pegs {
+ if filled[px] != '·' {
+ t.Fatalf("row %d lane %d overwrote peg at %d", row, lane, px)
+ }
+ }
+ }
+ }
+}
+
+func TestPlinkoHintsPrintOnceAtMidpoint(t *testing.T) {
+ pockets := [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16}
+ labels := casino.PlinkoPocketLabels(pockets)
+ isRuler := func(line string) bool {
+ r := []rune(line)
+ if len(r) != plinkoWidth || r[1] != ' ' {
+ return false
+ }
+ for k, label := range labels {
+ if r[2+4*k] != label {
+ return false
+ }
+ }
+ return true
+ }
+ for row := 0; row <= 16; row++ {
+ s := &casino.PlinkoSnapshot{Row: row, Total: 1, Pockets: pockets}
+ hasRuler := false
+ for _, line := range strings.Split(stripANSI(renderPlinko(s, "none", true)), "\n") {
+ hasRuler = hasRuler || isRuler(line)
+ }
+ if hasRuler != (row == plinkoRows/2) {
+ t.Fatalf("row %d: hint ruler present=%v", row, hasRuler)
+ }
+ // The pyramid row itself never carries pocket letters any more.
+ board := plinkoTestBoardRow(s)
+ for k, label := range labels {
+ if board[2+4*k] == label {
+ t.Fatalf("row %d: pocket hint leaked into the board", row)
+ }
+ }
+ }
+}
+
+func TestPlinkoLegendDownTheLeft(t *testing.T) {
+ s := &casino.PlinkoSnapshot{Pockets: [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16}, Total: 1}
+ labels := casino.PlinkoPocketLabels(s.Pockets)
+ if string(labels[:]) != "ABCDEFGHIHGFEDCBA" {
+ t.Fatalf("wrong labels: %s", string(labels[:]))
+ }
+ entries := plinkoLegendEntries(s)
+ if len(entries) != 9 {
+ t.Fatalf("want 9 distinct legend entries, got %d", len(entries))
+ }
+ for row := 0; row < len(entries); row++ {
+ s.Row = row
+ line := string(plinkoTestBoardRow(s))
+ want := fmt.Sprintf("%c %s", entries[row].label, entries[row].text)
+ if !strings.HasPrefix(line[1:], want) {
+ t.Fatalf("row %d: legend entry not on the left: %q", row, line)
+ }
+ if entries[row].label == 'A' && !strings.Contains(line, "16x") {
+ t.Fatal("legend missing the 16x edge payout")
+ }
+ }
+ // The legend stays clear of the pyramid as it widens.
+ s.Row = len(entries) - 1
+ if left := 32 - 2*s.Row; left <= 10 {
+ t.Fatalf("legend would collide with the pyramid: left border %d", left)
+ }
+ // The paytable prints the same entries, one per line, not a wide block.
+ s.Row = 0
+ paytable := stripANSI(renderPlinko(&casino.PlinkoSnapshot{Pockets: s.Pockets, Risk: "easy", Paytable: true}, "none", true))
+ for _, e := range entries {
+ if strings.Count(paytable, fmt.Sprintf("%c %s", e.label, e.text)) != 1 {
+ t.Fatalf("paytable legend missing or duplicated %c", e.label)
+ }
+ }
+ // Rounded ties, not just mirrored positions, share one legend entry.
+ s.Row = 16
+ s.Counts = [17]int{}
+ rail := strings.Split(renderPlinko(s, "none", true), "\n")[1]
+ if !strings.Contains(rail, "[A] [B] [C] [D] [E] [F] [G] [H] [I] [H] [G] [F] [E] [D] [C] [B] [A]") {
+ t.Fatal(rail)
+ }
+ s.Pockets[7], s.Pockets[8], s.Pockets[9] = 0.1, 0.1, 0.1
+ if got := plinkoLegendEntries(s); len(got) != 8 {
+ t.Fatalf("rounded ties duplicated: %d entries", len(got))
+ }
+}
+
+func TestPlinkoHintsAreDimWhite(t *testing.T) {
+ r, g, b := color.Xterm256ToRGB(plinkoHintColor())
+ if r != g || g != b {
+ t.Fatalf("hint is not white: %d,%d,%d", r, g, b)
+ }
+ if r > 95 {
+ t.Fatalf("hint is too bright: %d", r)
+ }
+ br, bg, bb := color.Xterm256ToRGB(plinkoBallColor(1, 1))
+ if r >= br || g >= bg || b >= bb {
+ t.Fatal("hint must be dimmer than a ball")
+ }
+}
diff --git a/internal/game/render_plinko.go b/internal/game/render_plinko.go
new file mode 100644
index 0000000..17f533a
--- /dev/null
+++ b/internal/game/render_plinko.go
@@ -0,0 +1,213 @@
+package game
+
+import (
+ "fmt"
+ "math"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+const plinkoWidth = 69
+
+// plinkoRows is the board height (16 bounces, 17 pockets).
+const plinkoRows = 16
+
+func plinkoBallColor(count, total int) int {
+ shade := 255
+ if total > 1 && count > 1 {
+ shade = 255 * (total - count) / (total - 1)
+ }
+ return color.NearestXterm256(255, shade, shade)
+}
+
+// plinkoGradientRGB is the board's shared hue: icy white at the center,
+// deepening through sky blue and violet to a saturated rose at the edges.
+func plinkoGradientRGB(x int) [3]int {
+ stops := [4][3]int{{215, 255, 255}, {85, 195, 255}, {145, 85, 245}, {255, 0, 95}}
+ distance := math.Abs(float64(x-34)) / 34
+ segment := min(2, int(distance*3))
+ blend := distance*3 - float64(segment)
+ var rgb [3]int
+ for i := range rgb {
+ rgb[i] = int(math.Round(float64(stops[segment][i])*(1-blend) + float64(stops[segment+1][i])*blend))
+ }
+ return rgb
+}
+
+func plinkoColorAt(x int, scale float64) int {
+ rgb := plinkoGradientRGB(x)
+ return color.NearestXterm256(
+ max(0, min(255, int(float64(rgb[0])*scale))),
+ max(0, min(255, int(float64(rgb[1])*scale))),
+ max(0, min(255, int(float64(rgb[2])*scale))))
+}
+
+// plinkoHintColor is the dim white used by the one-time midpoint pocket ruler.
+func plinkoHintColor() int { return 238 }
+
+// Foregrounds are deliberately faint so the balls stay the brightest thing
+// on the board.
+func plinkoPegColor(x int) int { return plinkoColorAt(x, 0.68) }
+func plinkoBorderColor(x int) int { return plinkoColorAt(x, 0.78) }
+func plinkoPocketColor(x int) int { return plinkoColorAt(x, 0.90) }
+
+func plinkoMultiplier(multiplier float64) string {
+ return strings.TrimSuffix(fmt.Sprintf("%.1f", multiplier), ".0") + "x"
+}
+
+type plinkoCell struct {
+ glyph rune
+ fg int
+ guide bool
+}
+type plinkoLine [plinkoWidth]plinkoCell
+
+func newPlinkoLine() plinkoLine {
+ var line plinkoLine
+ for x := range line {
+ line[x] = plinkoCell{glyph: ' ', fg: -1}
+ }
+ return line
+}
+func (line *plinkoLine) text(x int, text string, fg int) {
+ for _, c := range text {
+ if x >= len(line) {
+ break
+ }
+ line[x] = plinkoCell{glyph: c, fg: fg}
+ x++
+ }
+}
+func (line plinkoLine) render(mode string) string {
+ var b strings.Builder
+ for _, cell := range line {
+ fg := cell.fg
+ // Basic ANSI has no dim white; the dim attribute plus bright black is
+ // its closest readable stand-in for a faint white hint.
+ dim := false
+ if cell.guide && mode == "ansi" {
+ fg, dim = 7, true
+ }
+ b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Dim: dim}, string(cell.glyph)))
+ }
+ return b.String()
+}
+
+type plinkoLegendEntry struct {
+ label rune
+ text string
+ // pocket is the left-most pocket index that carries this label, used to
+ // tint the entry with that column's board hue.
+ pocket int
+}
+
+// plinkoLegendEntries is one entry per distinct payout, outside-to-center.
+// Mirrored and rounded-tie pockets share a letter and therefore one entry.
+func plinkoLegendEntries(s *casino.PlinkoSnapshot) []plinkoLegendEntry {
+ labels := casino.PlinkoPocketLabels(s.Pockets)
+ var entries []plinkoLegendEntry
+ seen := map[rune]bool{}
+ for k, label := range labels {
+ if seen[label] {
+ continue
+ }
+ seen[label] = true
+ entries = append(entries, plinkoLegendEntry{label: label, text: plinkoMultiplier(s.Pockets[k]), pocket: k})
+ }
+ return entries
+}
+
+// plinkoLegendText pads an entry to a fixed width so a guide letter in the
+// next column never butts directly against the multiplier.
+func plinkoLegendText(e plinkoLegendEntry) string {
+ return fmt.Sprintf("%c %-7s", e.label, e.text)
+}
+
+// The legend is written down the left, outside the pyramid, one entry per
+// row, so every value is known well before the ball reaches the pockets.
+func renderPlinkoLegend(s *casino.PlinkoSnapshot, mode string) []string {
+ var lines []string
+ for _, e := range plinkoLegendEntries(s) {
+ line := newPlinkoLine()
+ line.text(1, plinkoLegendText(e), plinkoPocketColor(2+4*e.pocket))
+ lines = append(lines, line.render(mode))
+ }
+ return lines
+}
+
+// plinkoHintLine is the one-time pocket ruler printed at the board's midpoint,
+// so players learn the pocket columns without a letter on every row.
+func plinkoHintLine(labels [17]rune, mode string) string {
+ hint := newPlinkoLine()
+ for k, label := range labels {
+ hint[2+4*k] = plinkoCell{glyph: label, fg: plinkoHintColor(), guide: true}
+ }
+ return hint.render(mode)
+}
+
+// The legend appears down the left, then each snapshot appends one scanline.
+// The pocket hints print once, as a ruler at the board's midpoint.
+func renderPlinko(s *casino.PlinkoSnapshot, mode string, unicode bool) string {
+ labels := casino.PlinkoPocketLabels(s.Pockets)
+ if s.Paytable {
+ title := "=== P L I N K O / " + strings.ToUpper(s.Risk) + " ==="
+ heading := newPlinkoLine()
+ heading.text((plinkoWidth-len(title))/2, title, plinkoPocketColor(34))
+ note := newPlinkoLine()
+ note.text(1, "Matching letters share a payout; returns include the stake.", plinkoPocketColor(34))
+ lines := []string{heading.render(mode), note.render(mode)}
+ lines = append(lines, renderPlinkoLegend(s, mode)...)
+ tail := newPlinkoLine()
+ tail.text(1, "White = one ball; shades to red = all balls overlapping.", plinkoPocketColor(34))
+ return strings.Join(append(lines, tail.render(mode)), "\n")
+ }
+ ball, peg := 'o', '.'
+ if unicode {
+ ball, peg = '●', '·'
+ }
+ line := newPlinkoLine()
+ left, right := 32-2*s.Row, 36+2*s.Row
+ line[left] = plinkoCell{glyph: '/', fg: plinkoBorderColor(left)}
+ line[right] = plinkoCell{glyph: '\\', fg: plinkoBorderColor(right)}
+ // Pegs sit at their true between-lane positions; they are never moved to
+ // make room for a guide letter.
+ for k := 1; k <= s.Row; k++ {
+ x := left + 4*k
+ line[x] = plinkoCell{glyph: peg, fg: plinkoPegColor(x)}
+ }
+ for k := 0; k <= s.Row; k++ {
+ count := s.Counts[k]
+ if count == 0 {
+ continue
+ }
+ glyph := ball
+ if mode == "none" && count > 1 {
+ glyph = rune('0' + count)
+ if count == 10 {
+ glyph = '*'
+ }
+ }
+ line[left+2+4*k] = plinkoCell{glyph: glyph, fg: plinkoBallColor(count, s.Total)}
+ }
+ if entries := plinkoLegendEntries(s); s.Row < len(entries) {
+ e := entries[s.Row]
+ line.text(1, plinkoLegendText(e), plinkoPocketColor(2+4*e.pocket))
+ }
+ lines := []string{line.render(mode)}
+ if s.Row == plinkoRows/2 {
+ lines = append(lines, plinkoHintLine(labels, mode))
+ }
+ if s.Row == 16 {
+ rail := newPlinkoLine()
+ for k, label := range labels {
+ x := 1 + 4*k
+ rail[x] = plinkoCell{glyph: '[', fg: plinkoPegColor(x)}
+ rail[x+1] = plinkoCell{glyph: label, fg: plinkoPocketColor(x + 1)}
+ rail[x+2] = plinkoCell{glyph: ']', fg: plinkoPegColor(x + 2)}
+ }
+ lines = append(lines, rail.render(mode))
+ }
+ return strings.Join(lines, "\n")
+}