From 80cff00ce97d1ca013c68e10de3130f15ac0fa51 Mon Sep 17 00:00:00 2001 From: soheima Date: Fri, 6 Nov 2026 18:31:36 +0100 Subject: [PATCH 1/3] feat(vibenet): share Base Runner score on X Adds a Share on X button to the game-over overlay. Each run picks one of two share lines at random, and clicks are tracked as block_runner_share. --- AGENTS.md | 1 + app/analytics/events.ts | 7 +++++ app/vibenet/demos/200/BlockRunner.tsx | 16 +++++++++-- app/vibenet/demos/200/lib/share.test.ts | 36 +++++++++++++++++++++++++ app/vibenet/demos/200/lib/share.ts | 23 ++++++++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 app/vibenet/demos/200/lib/share.test.ts create mode 100644 app/vibenet/demos/200/lib/share.ts diff --git a/AGENTS.md b/AGENTS.md index 19daf88..4c7e027 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,7 @@ This app uses Vercel Web Analytics. Two things must stay in place: | `trackExplorerActiveBlockJump(chain, jump)` | `app/internal-explorer/components/ActiveBlockButton.tsx` — zeronet latest/previous active block | | `trackValidityOrder(side, status)` | `app/vibenet/demos/validity/ValidityDemo.tsx` — conditional swap submit / include / expiry / replace | | `trackValidityRace(attempt, status)` | `app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx` — validity/manual comparison attempts | + | `trackBlockRunnerShare(variant, score)` | `app/vibenet/demos/200/BlockRunner.tsx` — Share on X button on the game-over screen | Add a helper (and a row here) for a new key journey; remove the helper if you remove its surface. Confirm the wiring with `grep -rn "analytics/events" app`. diff --git a/app/analytics/events.ts b/app/analytics/events.ts index b65d81d..f66e177 100644 --- a/app/analytics/events.ts +++ b/app/analytics/events.ts @@ -91,3 +91,10 @@ export function trackValidityRace( ): void { track('validity_race', { attempt, status }); } + +// --- Base Runner (200ms) demo --- + +// Fired when a player opens the X composer from the game-over screen. +export function trackBlockRunnerShare(variant: 'ate' | 'survived', score: number): void { + track('block_runner_share', { variant, score }); +} diff --git a/app/vibenet/demos/200/BlockRunner.tsx b/app/vibenet/demos/200/BlockRunner.tsx index 4ee5818..0df4921 100644 --- a/app/vibenet/demos/200/BlockRunner.tsx +++ b/app/vibenet/demos/200/BlockRunner.tsx @@ -17,6 +17,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '../../../components/ui/Button'; import { Text } from '../../../components/ui/Text'; +import { trackBlockRunnerShare } from '../../../analytics/events'; import { VIBENET_EXPLORER_PATH, VIBENET_RPC_URL, VIBENET_WS_URL } from '../../library/config'; import { connectJsonRpcStream } from '../validity/lib/stream'; import { @@ -49,6 +50,7 @@ import { submitScore, type BoardEntry, } from './lib/leaderboard'; +import { pickShareVariant, shareText, type ShareVariant, xShareUrl } from './lib/share'; import { Sound } from './lib/sound'; import { BOSS_CLOSED, @@ -646,6 +648,7 @@ export function BlockRunner() { const [score, setScore] = useState(0); const [best, setBest] = useState(0); const [phase, setPhase] = useState('ready'); + const [shareVariant, setShareVariant] = useState('ate'); const [head, setHead] = useState(null); const [rate, setRate] = useState(0); const [feed, setFeedState] = useState<'connecting' | 'live' | 'polling' | 'quiet'>('connecting'); @@ -804,6 +807,11 @@ export function BlockRunner() { } }, [submitState]); + const shareOnX = useCallback(() => { + trackBlockRunnerShare(shareVariant, score); + window.open(xShareUrl(shareText(score, shareVariant)), '_blank', 'noopener,noreferrer'); + }, [score, shareVariant]); + // Fullscreen: the stage wrapper goes fullscreen; the canvas letterboxes // inside it with object-contain so the aspect never distorts. const toggleFullscreen = useCallback(() => { @@ -910,6 +918,7 @@ export function BlockRunner() { if (next.phase === 'running') sound().startMusic(); else sound().stopMusic(); if (next.phase === 'dead') { + setShareVariant(pickShareVariant()); setBest((b) => { const nb = Math.max(b, next.score); if (nb !== b) writeBest(nb); @@ -1032,10 +1041,13 @@ export function BlockRunner() { - {phase === 'dead' && score > 0 && (score > chainBest || submitState !== '') ? ( + {phase === 'dead' && score > 0 ? (
- {submitState === 'done' ? ( + + {score <= chainBest && submitState === '' ? null : submitState === 'done' ? ( On the board — tap the game to run again. diff --git a/app/vibenet/demos/200/lib/share.test.ts b/app/vibenet/demos/200/lib/share.test.ts new file mode 100644 index 0000000..3487b8d --- /dev/null +++ b/app/vibenet/demos/200/lib/share.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { pickShareVariant, SHARE_URL, shareText, xShareUrl } from './share'; + +describe('shareText', () => { + it('writes the "ate" line', () => { + expect(shareText(42, 'ate')).toBe( + 'I ate 42 blocks on Base Runner\n\nHow long can you survive? https://chain.base.org/vibenet/demos/200', + ); + }); + + it('writes the "survived" line', () => { + expect(shareText(7, 'survived')).toBe( + 'I survived 7 blocks on Base Runner\n\nSee how long you can last: https://chain.base.org/vibenet/demos/200', + ); + }); +}); + +describe('xShareUrl', () => { + it('builds an X post intent that round-trips the text', () => { + const text = shareText(42, 'ate'); + const url = new URL(xShareUrl(text)); + expect(url.origin + url.pathname).toBe('https://x.com/intent/post'); + expect(url.searchParams.get('text')).toBe(text); + expect(url.searchParams.get('text')).toContain(SHARE_URL); + }); +}); + +describe('pickShareVariant', () => { + it('maps the random range onto both variants', () => { + expect(pickShareVariant(() => 0)).toBe('ate'); + expect(pickShareVariant(() => 0.49)).toBe('ate'); + expect(pickShareVariant(() => 0.5)).toBe('survived'); + expect(pickShareVariant(() => 0.999)).toBe('survived'); + }); +}); diff --git a/app/vibenet/demos/200/lib/share.ts b/app/vibenet/demos/200/lib/share.ts new file mode 100644 index 0000000..8abd665 --- /dev/null +++ b/app/vibenet/demos/200/lib/share.ts @@ -0,0 +1,23 @@ +// Post-run share copy for X. Two lines rotate at random per run so the feed +// doesn't see the same post over and over. + +export const SHARE_URL = 'https://chain.base.org/vibenet/demos/200'; + +export type ShareVariant = 'ate' | 'survived'; + +export const SHARE_VARIANTS: readonly ShareVariant[] = ['ate', 'survived']; + +export function shareText(score: number, variant: ShareVariant): string { + return variant === 'ate' + ? `I ate ${score} blocks on Base Runner\n\nHow long can you survive? ${SHARE_URL}` + : `I survived ${score} blocks on Base Runner\n\nSee how long you can last: ${SHARE_URL}`; +} + +/** X's web intent: opens the composer prefilled with the text. */ +export function xShareUrl(text: string): string { + return `https://x.com/intent/post?text=${encodeURIComponent(text)}`; +} + +export function pickShareVariant(random: () => number = Math.random): ShareVariant { + return SHARE_VARIANTS[Math.floor(random() * SHARE_VARIANTS.length)] ?? 'ate'; +} From 78f8b2de1c16d6b0d7451db504226ab1682d7870 Mon Sep 17 00:00:00 2001 From: soheima Date: Mon, 9 Nov 2026 12:24:35 +0100 Subject: [PATCH 2/3] fix(vibenet): credit 200ms blocks to Denim in Block Runner Block Runner said Base Cobalt mints a block every 200 ms. 200 ms blocks and millisecond timestamps ship in Denim, so the page copy and game comments now name Denim. --- app/vibenet/demos/200/BlockRunner.tsx | 2 +- app/vibenet/demos/200/lib/game.ts | 6 +++--- app/vibenet/demos/200/page.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/vibenet/demos/200/BlockRunner.tsx b/app/vibenet/demos/200/BlockRunner.tsx index 0df4921..8bc0aab 100644 --- a/app/vibenet/demos/200/BlockRunner.tsx +++ b/app/vibenet/demos/200/BlockRunner.tsx @@ -1,7 +1,7 @@ 'use client'; // Block Runner: an 8-bit pixel runner in Base colors where the vibenet chain is -// the spawner. Every new head (one per 200 ms under Cobalt) is spat out as a +// the spawner. Every new head (one per 200 ms under Denim) is spat out as a // block by the boss on the right edge. The player is a round Base-blue glutton: // tap ONE button — Space, X, or anywhere on the picture — and each tap is a // bite: the mouth opens briefly, swallows at most one block — one eaten. diff --git a/app/vibenet/demos/200/lib/game.ts b/app/vibenet/demos/200/lib/game.ts index 6e6eab7..4ce762a 100644 --- a/app/vibenet/demos/200/lib/game.ts +++ b/app/vibenet/demos/200/lib/game.ts @@ -2,7 +2,7 @@ // be unit-tested and rendered by anything. // // The chain is the spawner: every new vibenet head (one per 200 ms under -// Cobalt) becomes one block that the boss on the right edge spits out. The +// Denim) becomes one block that the boss on the right edge spits out. The // player is a round little glutton who auto-runs and holds ONE button to // inhale: the nearest block in range is dragged into its mouth and swallowed — // one eaten — revealing its number and 200 ms slot. Busy walls are @@ -76,7 +76,7 @@ export const RESTART_GRACE = 0.4; export type Head = { number: number; - /** Cobalt millisecond timestamp, or null on chains without it. */ + /** Denim millisecond timestamp, or null on chains without it. */ timestampMs: number | null; gasUsed: number; }; @@ -224,7 +224,7 @@ export function weightFor(w: number): number { return Math.min(2, Math.max(1, Math.round(w / 16) - 1)); } -/** The 200 ms slot inside the second, `.000` … `.800`, or null pre-Cobalt. */ +/** The 200 ms slot inside the second, `.000` … `.800`, or null pre-Denim. */ export function slotOf(timestampMs: number | null): string | null { if (timestampMs === null) return null; return `.${String(timestampMs % 1000).padStart(3, '0')}`; diff --git a/app/vibenet/demos/200/page.tsx b/app/vibenet/demos/200/page.tsx index a1e4a40..7d6472d 100644 --- a/app/vibenet/demos/200/page.tsx +++ b/app/vibenet/demos/200/page.tsx @@ -12,7 +12,7 @@ export default function BlockRunnerPage() { Block Runner - Base's Cobalt upgrade mints a block every 200 ms. Block Runner is a one-button game built on that + Base's Denim upgrade mints a block every 200 ms. Block Runner is a one-button game built on that cadence: every obstacle is a real vibenet block, pushed to the screen the moment the chain seals it. Tap to bite a block, read its number, and see how long you can keep up. From 79f1188eef96b76dba253c606dd2af65c2a76ff7 Mon Sep 17 00:00:00 2001 From: soheima Date: Mon, 9 Nov 2026 14:10:16 +0100 Subject: [PATCH 3/3] feat(vibenet): ramp Base Runner difficulty and salt wall heights Speed and wall size keep rising past the early caps, hearts refill every 25 bites, FULL lasts 1.5 s and costs more each time, and chewing slows on a full belly as the run goes on. Each block gets a stable random height nudge seeded from its block number; spawn timing stays one block per 200 ms. Self-play now asserts that perfect tapping dies within 30-120 s. --- app/vibenet/demos/200/lib/game.test.ts | 61 ++++++++++++++++- app/vibenet/demos/200/lib/game.ts | 79 +++++++++++++++++++--- app/vibenet/demos/200/lib/selfplay.test.ts | 11 +++ 3 files changed, 137 insertions(+), 14 deletions(-) diff --git a/app/vibenet/demos/200/lib/game.test.ts b/app/vibenet/demos/200/lib/game.test.ts index 7141de9..8268cf9 100644 --- a/app/vibenet/demos/200/lib/game.test.ts +++ b/app/vibenet/demos/200/lib/game.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + blockSalt, blockSizeFor, + chewTime, + fullGain, blockLabel, BOSS_X, createGame, @@ -98,7 +101,9 @@ describe('speedFor', () => { it('ramps with score and caps', () => { expect(speedFor(0)).toBe(560); expect(speedFor(10)).toBe(590); - expect(speedFor(1_000)).toBe(720); + expect(speedFor(60)).toBe(720); + expect(speedFor(160)).toBe(800); + expect(speedFor(1_000)).toBe(960); }); }); @@ -285,7 +290,7 @@ describe('fullness / stuffed', () => { } expect(g.stuffed).toBe(true); expect(announced).toBe(true); - expect(g.fullTime).toBeGreaterThan(2); + expect(g.fullTime).toBeGreaterThan(1); // A new block is not eaten while FULL — it just rolls under him. g = { ...g, blocks: [blk({ id: 10, x: PLAYER_X + 260, h: 60, weight: 1 })] }; const before = g.score; @@ -444,7 +449,7 @@ describe('step', () => { it('swallowing blocks earns a heart back', () => { - let g = tap({ ...start(createGame()), hearts: 1, heartProgress: 11 }); + let g = tap({ ...start(createGame()), hearts: 1, heartProgress: 24 }); g = { ...g, blocks: [blk({ id: 9, x: PLAYER_X + 200, h: 60 })] }; g = run(g, 0.5); expect(g.hearts).toBe(2); @@ -458,3 +463,53 @@ describe('step', () => { expect(g.shake).toBe(0); }); }); + +describe('rising pressure', () => { + it('chews longer on a fuller belly once the run is under way', () => { + expect(chewTime(0, 0)).toBeCloseTo(0.12); + expect(chewTime(1, 0)).toBeCloseTo(0.12); + expect(chewTime(0, 500)).toBeCloseTo(0.12); + // A full belly late in a run chews past the 200 ms gap between blocks. + expect(chewTime(1, 500)).toBeGreaterThan(0.2); + expect(chewTime(0.5, 120)).toBeGreaterThan(chewTime(0.5, 60)); + }); + + it('makes each FULL need more food than the last', () => { + expect(fullGain(1, 0)).toBeCloseTo(0.11); + expect(fullGain(1, 1)).toBeLessThan(fullGain(1, 0)); + expect(fullGain(1, 4)).toBeCloseTo(0.055); + }); + + it('keeps growing walls after the early difficulty cap', () => { + expect(blockSizeFor(300_000, 300).h).toBeGreaterThan(blockSizeFor(300_000, 60).h); + // Late game bumps every other block, not just every fourth. + expect(blockSizeFor(300_000, 300, 6).w).toBe(48); + expect(blockSizeFor(300_000, 60, 6).w).toBe(32); + }); + + it('counts FULL modes reached', () => { + let g = tap({ ...start(createGame()), fullness: 0.99 }); + g = { ...g, blocks: [blk({ id: 3, x: PLAYER_X + 120, h: 40 })] }; + g = run(g, 0.5); + expect(g.stuffed).toBe(true); + expect(g.feasts).toBe(1); + }); +}); + +describe('block salt', () => { + it('is a stable 0..1 value per block number', () => { + const values = Array.from({ length: 1_000 }, (_, i) => blockSalt(3_000_000 + i)); + expect(values.every((v) => v >= 0 && v < 1)).toBe(true); + expect(blockSalt(3_000_123)).toBe(blockSalt(3_000_123)); + // Spread across the range, not clumped. + expect(values.filter((v) => v < 0.25).length).toBeGreaterThan(150); + expect(values.filter((v) => v > 0.75).length).toBeGreaterThan(150); + }); + + it('gives identical-gas blocks different heights, inside the block limits', () => { + const heights = Array.from({ length: 200 }, (_, i) => blockSizeFor(210_000, 30, 3_000_000 + i).h); + expect(new Set(heights).size).toBeGreaterThan(20); + expect(Math.min(...heights)).toBeGreaterThanOrEqual(30); + expect(Math.max(...heights)).toBeLessThanOrEqual(112); + }); +}); diff --git a/app/vibenet/demos/200/lib/game.ts b/app/vibenet/demos/200/lib/game.ts index 4ce762a..6b25a22 100644 --- a/app/vibenet/demos/200/lib/game.ts +++ b/app/vibenet/demos/200/lib/game.ts @@ -23,16 +23,23 @@ export const PLAYER_H = 48; export const INHALE_RANGE = 280; /** Extra approach speed suction adds, divided by the block's weight. */ const PULL_SPEED = 1300; -/** Seconds of chew (mouth shut, cheeks full) after each swallow. */ +/** Seconds of chew (mouth shut, cheeks full) after each swallow, on an empty belly. */ const PUFF_TIME = 0.12; +/** Extra chew at a full belly, as a fraction of PUFF_TIME. Near a full belly a chew + * outlasts the 200 ms gap between blocks, so a full belly makes walls bite. */ +const CHEW_SLOWDOWN = 0.9; +/** Score over which the full-belly chew slowdown phases in. */ +const CHEW_RAMP = 240; /** The mouth must be visibly open this long before a pulled block goes down. */ const OPEN_MIN = 0.07; /** One tap opens the mouth for this long — one bite per tap. */ const INHALE_BURST = 0.28; /** Fullness gained per unit of block weight. Only the full-mode cycle empties it. */ const FULL_PER_WEIGHT = 0.11; +/** Each FULL already reached this run makes the next one need this much more food. */ +const FULL_GROWTH = 0.25; /** Seconds of FULL mode: too round to hurt, rolling over every block. */ -const FULL_TIME = 2.5; +const FULL_TIME = 1.5; /** How fast he slims back down once full mode ends. */ const DEFLATE_RATE = 2.2; @@ -57,9 +64,19 @@ const BLOCK_GRAVITY = 2400; /** Sprite pixels per block; width = 16 × scale. */ const MIN_BLOCK_H = 30; const MAX_BLOCK_H = 112; +/** Total height spread (px) of the per-block salt: each wall lands up to half + * this taller or shorter than its gas alone would make it. */ +const HEIGHT_SALT = 40; const BASE_SPEED = 560; const MAX_SPEED_BONUS = 160; +/** Past the early cap, speed keeps creeping up per block eaten… */ +const LATE_SPEED_START = 60; +const LATE_SPEED_PER_BLOCK = 0.8; +/** …to this much more, so long runs keep getting harder instead of flatlining. */ +const LATE_SPEED_MAX = 240; +/** Score over which block size keeps growing after the early difficulty cap. */ +const LATE_SIZE_SPAN = 240; const GRAVITY = 2000; const LABEL_LIFE = 1.2; const PARTICLE_LIFE = 0.6; @@ -69,7 +86,7 @@ const BOSS_MOUTH_OPEN = 0.16; export const MAX_HEARTS = 3; const HURT_INVULN = 1.1; /** Bursts needed to earn a heart back. */ -const HEART_REFILL = 12; +const HEART_REFILL = 25; /** Seconds of small blocks at the start of a run, so the rhythm lands first. */ const WARMUP = 8; export const RESTART_GRACE = 0.4; @@ -147,6 +164,8 @@ export type Game = { invuln: number; /** Bursts banked toward the next heart. */ heartProgress: number; + /** FULL modes reached this run; each one makes the next need more food. */ + feasts: number; nextId: number; /** Set for one step when something audible happened; the renderer clears it. */ events: GameEvent[]; @@ -183,14 +202,41 @@ export function createGame(): Game { hearts: MAX_HEARTS, invuln: 0, heartProgress: 0, + feasts: 0, nextId: 1, events: [], }; } -/** Scroll speed: ramps gently with score, dino-style, and caps. */ +/** Scroll speed: ramps quickly early, dino-style, then keeps creeping up to a higher cap. */ export function speedFor(score: number): number { - return BASE_SPEED + Math.min(score * 3, MAX_SPEED_BONUS); + const late = Math.min(Math.max(0, score - LATE_SPEED_START) * LATE_SPEED_PER_BLOCK, LATE_SPEED_MAX); + return BASE_SPEED + Math.min(score * 3, MAX_SPEED_BONUS) + late; +} + +/** Chew time after a swallow: the fuller the belly, the longer the mouth stays + * busy — an effect that phases in over the first CHEW_RAMP blocks of a run. */ +export function chewTime(fullness: number, score = CHEW_RAMP): number { + const ramp = Math.min(1, Math.max(0, score / CHEW_RAMP)); + return PUFF_TIME * (1 + CHEW_SLOWDOWN * ramp * Math.min(1, Math.max(0, fullness))); +} + +/** Belly gained by a swallow; every FULL already reached makes the next one cost more. */ +export function fullGain(weight: number, feasts: number): number { + return (FULL_PER_WEIGHT * weight) / (1 + FULL_GROWTH * feasts); +} + +/** + * A stable 0..1 value per block number (a 32-bit integer hash). Seeding from + * the block keeps the salt the same for everyone watching the chain and keeps + * tests deterministic — and it only ever touches size, never spawn timing. + */ +export function blockSalt(blockNumber: number): number { + let x = (blockNumber | 0) ^ 0x9e3779b9; + x = Math.imul(x ^ (x >>> 16), 0x85ebca6b); + x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35); + x ^= x >>> 16; + return (x >>> 0) / 4294967296; } /** @@ -203,11 +249,18 @@ export function speedFor(score: number): number { export function blockSizeFor(gasUsed: number, score = 0, blockNumber = 0, time = WARMUP): { w: number; h: number } { const activity = Math.min(1, Math.max(0, (gasUsed - 200_000) / 800_000)); const difficulty = Math.min(1, score / 60); - let h = Math.round(MIN_BLOCK_H + (MAX_BLOCK_H - MIN_BLOCK_H) * Math.min(1, activity * (0.55 + 0.45 * difficulty) + difficulty * 0.25)); + // Late game: past the early cap, walls keep getting taller and wider. + const late = Math.min(1, Math.max(0, score - 60) / LATE_SIZE_SPAN); + const gasH = + MIN_BLOCK_H + + (MAX_BLOCK_H - MIN_BLOCK_H) * Math.min(1, activity * (0.55 + 0.45 * difficulty) + difficulty * 0.25 + late * 0.25); + // Salt: quiet stretches of the chain would otherwise be rows of identical + // walls, so each block gets a stable random nudge on top of its gas height. + let h = Math.round(Math.min(MAX_BLOCK_H, Math.max(MIN_BLOCK_H, gasH + (blockSalt(blockNumber) - 0.5) * HEIGHT_SALT))); // Base width from activity; every fourth block gets a size bump once the run // is under way, so widths mix rather than stepping up in lockstep. let scale = activity < 0.15 ? 2 : activity < 0.5 ? 3 : 4; - if (difficulty > 0.3 && blockNumber % 4 === 0) scale = Math.min(4, scale + 1); + if (difficulty > 0.3 && blockNumber % (late >= 0.5 ? 2 : 4) === 0) scale = Math.min(4, scale + 1); if (difficulty < 0.15 && scale === 4) scale = 3; // Warm-up: the first seconds are small blocks the runner can step over, so // the 200 ms rhythm is felt before the walls arrive. Every block still lands. @@ -332,6 +385,7 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G let score = game.score; let hearts = game.hearts; let heartProgress = game.heartProgress; + let feasts = game.feasts; let invuln = Math.max(0, game.invuln - dt); // FULL mode leaves afterimages — the unstoppable barrel-along up top. @@ -451,17 +505,18 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G // the belly instead (they fill it twice as fast). score += 1; heartProgress += 1; - puffed = PUFF_TIME; labels = [...labels, { x: mouthX + 20, y: pulled.y - 14, text: blockLabel(pulled), age: 0 }]; particles = [...particles, ...shards(mouthX + 10, pulled.y + pulled.h / 2, rng)]; events.push('gulp'); mouthOpen = 0; inhaling = false; inhaleTime = 0; - fullness = Math.min(1, fullness + FULL_PER_WEIGHT * pulled.weight); + fullness = Math.min(1, fullness + fullGain(pulled.weight, feasts)); + puffed = chewTime(fullness, score); if (fullness >= 1) { stuffed = true; fullTime = FULL_TIME; + feasts += 1; events.push('stuffed'); } } else { @@ -501,14 +556,15 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G blocks = blocks.filter((b) => b.id !== bonked.id); score += 1; heartProgress += 1; - puffed = PUFF_TIME; mouthOpen = 0; inhaling = false; inhaleTime = 0; - fullness = Math.min(1, fullness + FULL_PER_WEIGHT * bonked.weight); + fullness = Math.min(1, fullness + fullGain(bonked.weight, feasts)); + puffed = chewTime(fullness, score); if (fullness >= 1) { stuffed = true; fullTime = FULL_TIME; + feasts += 1; events.push('stuffed'); } labels = [...labels, { x: PLAYER_X + PLAYER_W, y: bonked.y - 14, text: blockLabel(bonked), age: 0 }]; @@ -594,6 +650,7 @@ export function step(game: Game, dt: number, rng: () => number = Math.random): G hearts, invuln: dead ? 0 : invuln, heartProgress, + feasts, events, }; } diff --git a/app/vibenet/demos/200/lib/selfplay.test.ts b/app/vibenet/demos/200/lib/selfplay.test.ts index 853120c..853ae4e 100644 --- a/app/vibenet/demos/200/lib/selfplay.test.ts +++ b/app/vibenet/demos/200/lib/selfplay.test.ts @@ -80,6 +80,17 @@ describe('self-play balance', () => { }); + it('even perfect tapping dies within a couple of minutes', () => { + // Rising pressure: speed, wall size, chew time and FULL cost keep climbing, + // so no tap rhythm lasts forever. Today a perfect bot averages ~50 s. + for (const bot of [tapBot, (g: Game) => tap(g)]) { + const r = play(bot, 300, 2); + expect(r.deaths).toBeGreaterThanOrEqual(2); + expect(r.longestRun).toBeLessThan(120); + expect(r.longestRun).toBeGreaterThan(30); + } + }); + it('never inhaling loses shortly after the warm-up', () => { const r = play(idleBot, 30, 3); expect(r.deaths).toBeGreaterThanOrEqual(1);