BeamNG.Earth
BeamNG.drive용 1:1 비율 실시간 지구, 실제 고도·위성 이미지·부동 원점·보조 서버에서 LOD 스트리밍
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Pull. Collect. Battle.
TzDeck turns artwork listed on OBJKT into trading cards. Collectors open virtual booster packs, inspect new finds, browse the NFTs already in their wallet as a deck, and battle those cards against other collectors' for XP and levels.README 전문 보기 ↗Pull. Collect. Battle.
TzDeck turns artwork listed on OBJKT into trading cards. Collectors open virtual booster packs, inspect new finds, browse the NFTs already in their wallet as a deck, and battle those cards against other collectors' for XP and levels.
TzDeck is a discovery layer, not a marketplace. It does not mint, sell, or transfer NFTs. Collection activity happens through OBJKT and its Tezos marketplace contracts.
A pack draws from four windows of active OBJKT listings, fetched in one request. One window lands somewhere in the newest 20,000 listings, about the last three weeks. The other three each start at a random moment between September 2022, when the oldest active listing was made, and now. So a pack can turn up art from any era of the market, not just this month's. No more than two cards in a pack come from one artist. npm run check:diversity draws 60 live packs and reports artist spread, listing age, and the rarity mix.
Booster packs exclude tokens that OBJKT has flagged, tokens from collections that are no longer live, tokens with a flagged creator, and anything on TzDeck's own denylist. Roughly 99% of active listings pass, and every exclusion is recorded with the rule that caused it. See docs/pull-filter-spec.md.
The filter needs no database. Losing DATABASE_URL degrades it to the three OBJKT rules rather than failing a pack. The denylist is a manual override on top.
Manage the denylist with npm run denylist against DATABASE_URL:
npm run denylist -- --list
npm run denylist -- --add KT1… [--token <id>] --reason "confirmed impersonation"
npm run denylist -- --remove KT1… [--token <id>]
Entries take effect within 60 seconds, across instances, without a deploy. A --token id denylists one token; omitting it denylists the whole contract.
GET /api/random-pack?count=5 (or POST with { "count": 5 }) returns one pack of 3 to 10 cards. Anyone may call it, including other projects. Each pack costs TzDeck several OBJKT queries, so it is limited to 30 packs per minute per IP across both methods. Past that it answers 429 with a Retry-After header. Please send a User-Agent that names your project. The limit needs DATABASE_URL; without a database, packs are served unmetered.
TzDeck rarity is a deterministic display classification, not an on-chain NFT trait or a weighted pull probability. After a card is selected, TzDeck assigns the first matching tier from highest to lowest using the token's total edition supply and current OBJKT listing price:
| Rarity | Deterministic rule |
|---|---|
| Legendary | Exactly 1 edition and at least 1,000 ꜩ |
| Epic | 5 or fewer editions and at least 100 ꜩ, or any supply at 1,000 ꜩ or more |
| Rare | 10 or fewer editions and at least 25 ꜩ, or any supply at 250 ꜩ or more |
| Uncommon | 10 or fewer editions and at least 5 ꜩ, or any supply at 50 ꜩ or more |
| Common | Everything else |
Every tier needs scarcity and price together, or a high enough price alone. Legendary always needs a 1 of 1. Rules are evaluated from Legendary downward, so the first match wins. The ladder lives in PRICED_RARITY_LADDER in src/lib/rarity.ts.
Booster-pack contents are randomized from active OBJKT listings, but the rarity assigned to each selected card is deterministic. Cards in My Deck are classified by edition supply alone because wallet holdings do not include a listing price. As a result, the same NFT can show a different rarity in a booster pack, higher or lower, depending on its listing price. A 1 of 1 listed at 3 ꜩ is Common in a pack and Legendary in a deck.
The deck-only supply ladder is Legendary for a 1 of 1, Epic for editions of 5 or fewer, Rare for editions of 10 or fewer, Uncommon for editions of 25 or fewer, and Common for larger editions -- the same five-tier scale used by My Deck's battle system (see below). Its ceilings live in SUPPLY_RARITY_MAX_EDITIONS, apart from the priced ladder, so recalibrating pack rarity never changes a battle card's stats.
The priced ladder is calibrated against the pack draw itself, because it only ever grades pack cards and a pack is not a uniform sample of the market. Over 100 live packs in September 2026, it graded 2.6% of cards Legendary, 8.8% Epic, 16.8% Rare, 26.0% Uncommon, and 45.8% Common. About one pack in nine held a Legendary. npm run calibrate:rarity draws 100 live packs, prints the mix and price percentiles by edition size, and fails when a tier outnumbers the tier below it or Legendary passes 4% or Epic passes 12%.
The wishlist lives in the browser's localStorage under tzdeck_wishlist, so clearing site data or switching browsers loses it. Export downloads the saved cards as a dated tzdeck-wishlist-YYYY-MM-DD.json file; Import reads one back, including from the empty-wishlist screen.
An import merges rather than replaces -- cards already saved keep their place, and new ones are appended. The file's own contract_address and token_id are the only identity TzDeck trusts from it: every card is re-resolved against OBJKT on import, so prices and rarities reflect the market now rather than whenever the file was written. A card OBJKT no longer lists keeps its artwork and loses its price, falling back to the supply-only rarity ladder; if OBJKT can't be reached at all, the import still succeeds on the file's stored values and says which cards may be out of date.
Add saves a single card without a file, from the populated wishlist or the empty screen. Paste an OBJKT token link -- https://objkt.com/asset/KT1.../123, with or without the scheme, and with a trailing slash or a ?ref= query if that is how it was copied -- or type the contract and token id on their own as KT1.../123 or KT1...:123. Anything else is refused and left in the box for editing. A card already saved is a no-op that says so. The card is resolved against OBJKT on add, like an imported card, so its price and rarity are current and a link OBJKT has no token for saves nothing.
Clear Wishlist asks for confirmation before it wipes anything, naming how many cards are at stake; the prompt opens with focus on Cancel, and Escape or a click outside backs out.
Entries the file can't justify are dropped instead of failing the whole import: a card with no contract or token id, a repeat of one already read, or an image URL that isn't https: or ipfs:. The OBJKT link on each imported card is rebuilt from its contract and token id rather than taken from the file.
Connected wallets can pit an owned card against another wallet's card for XP and levels, using the same edition-based Power/HP derivation as the deck rarity ladder above. Battling needs a Postgres database (DATABASE_URL) and two additional env vars (BATTLE_AUTH_SECRET, BATTLE_APP_ID) in .env.local; run npm run migrate once against that database before battling locally (see Database migrations).
Two scripts explore the combat math without a database or a running server:
npm run simulate -- 5000 # run 5000 battles between fresh random cards, report win/draw rates
npm run simulate -- --help # see all flags: fixed matchups, variance, seed, CSV export
npm run validate:tiebreak # the R14 overkill-tiebreak's own fixed acceptance run
simulate is the general-purpose tool for exploring balance: it defaults to rolling a new random card for each side every trial, or pins a specific matchup via --a-editions/--a-desc/--a-level (and --b-* for the defender). Pass --seed=N for a reproducible run or --csv=path.csv to export one row per trial.
Anyone can watch a battle without a wallet. Watch a demo battle on My Deck's connect screen runs the TzDeck Demo Card through a fixed fight, and the swords button on a revealed booster-pack card tries that card instead, with a fresh random fight each time. Both use the real combat math (src/lib/battle/demo.ts): a level-1 copy of the card against the Common Trainer at its real stats. Everything runs in the browser, so nothing is signed or saved, and no XP, cooldown or daily attack is spent. Opening a demo sends one anonymous demo_battle_started analytics event, tagged only with where it started (deck or pack), and each Replay sends a demo_battle_replayed tagged the same way.
The demo card's fight is pinned by DEMO_SHOWCASE_SEED so the first thing a visitor sees includes a miss, a critical hit and a close win. demo.test.ts fails if a rules change alters that fight; when it does, pick a new seed that still shows all three.
Does battling ever cost Tezos? No. Every battle action -- matchmaking, direct challenges, opting in, refreshing holdings -- is authenticated by asking your wallet to sign a message (wallet.client.requestSignPayload in WalletContext.tsx), never by broadcasting an on-chain operation. Nothing is transferred and no gas or storage fee is paid.
Can I battle an NPC instead of another wallet? Yes -- five fixed "trainer" opponents, one per rarity tier, always available with no opt-in required on their side. Higher tiers unlock as your card levels up, you may still challenge any tier you've already unlocked, and trainer battles draw from their own separate daily allowance (trainer_attack_count/trainer_attack_reset_at) rather than the PvP one. A loss to a trainer still costs the normal recovery cooldown, just like a PvP loss.
Does the daily attack limit reset at a specific time? Yes, at midnight UTC. commit_battle resets a wallet's attack_count to 1 the first time it attacks after its attack_reset_at has passed, and sets the next reset to date_trunc('day', now()) + interval '1 day'. The reset is lazy (evaluated the next time that wallet attacks, not on a schedule) and shared across every card the wallet owns.
Does the defense cap reset the same way? Yes, identically -- same per-wallet date_trunc('day', ...) + interval '1 day' boundary, just evaluated when the wallet is picked as a defender instead of when it attacks.
Does the recovery cooldown work the same way? No. Recovery is a fixed duration counted from the moment a card loses, not aligned to midnight: recovery_until = settled_at + interval, with the interval hardcoded as 4 hours for an attacker's loss and 1 hour for a defender's loss. It is also per-card, not per-wallet.
Is the recovery duration configurable anywhere? No. It lives only as PL/pgSQL constants (v_offensive_recovery, v_defensive_recovery) inside the commit_battle function in migrations/. Changing it means shipping a new migration that redefines the function.
Does the XP/leveling system have a similar cap? Level itself is uncapped -- levelForXp in rules.ts keeps climbing against a quadratic XP threshold with no ceiling, and the Power/HP level multiplier scales with it forever. Three of the combat modifiers built on top of level do cap out, each a plain constant in rules.ts: critical-hit chance caps at 20% (level 39), critical-hit multiplier caps at 3.0x (level 31), and miss chance floors at 1% (also around level 31).
Does the SQL side duplicate and test those caps? No. commit_battle never recomputes combat stats -- it takes the client-computed attackerStats/defenderStats/combat as opaque input and only records them. The one server-side formula SQL does recompute independently (so a client can't lie about it) is the anti-farming XP decay, which is why it's the only one with a parity test (commitBattle.test.ts's "decay parity" case).
Does that anti-farming decay reset per opponent? Per opponent wallet pair, and it's a rolling window rather than a hard reset: commit_battle counts your wins against that specific wallet in the trailing 7 days and scales the XP award down by 0.5 ^ count (floored at 10%). Beating a different wallet starts back at full XP; wins against the same wallet older than 7 days age out of the count.
Does the win/loss battle log ever get pruned? Yes -- battle_log rows older than 30 days are swept opportunistically inside commit_battle, the same pattern check_rate_limit uses for rate_limits (see Database migrations). Nothing reads battle_log beyond the 7-day decay window, so 30 days is ample.
The battle system's schema lives in migrations/ as numbered SQL files. Both commands read DATABASE_URL:
npm run migrate:status # print the plan and exit, writing nothing
npm run migrate # apply every migration that has not run yet
migrate records each applied filename and its checksum, so a second run is a no-op. migrate:status neither locks nor writes, so it is safe to point at any database, production included.
An applied migration is checksummed, so its file must never be edited afterward. An edited file makes the next run refuse to apply anything until the file matches what was recorded. Fix a mistake by adding a new numbered migration on top instead.
Production migrations run from .github/workflows/migrate.yml, which triggers once CI goes green on main and can also be started by hand from the Actions tab. Before it can work, the operator adds a PRODUCTION_DATABASE_URL secret to the repository's production environment under Settings > Environments. That environment is also where required reviewers go if a production migration should need human approval.
Give the secret the direct, non-pooled Neon connection string. A run holds a session-level advisory lock so two migrations cannot overlap. A transaction-mode pooler can hand each statement a different backend, taking the lock on one connection and releasing it on another, while the per-migration transactions still succeed, so the failure is silent. Applying through a -pooler host is therefore refused outright. Reads never take the lock, so migrate:status works against either host. When the secret is empty the workflow fails with that instruction instead of connecting to nothing.
PRODUCTION_DATABASE_URL is a GitHub Actions secret, read only by the workflow. It has nothing to do with Vercel's environment variables, where the app reads DATABASE_URL at runtime. Vercel wants the pooled endpoint for that one; only migrations need the direct host.
GitHub only offers a workflow once it is on the default branch, so the first production migration has to be dispatched by hand after this lands on main.
Vercel ships the moment main moves and the deployed battle routes need their tables, so run the first production migration immediately before or after the merge.
Once CI goes green on main, .github/workflows/discord-updates.yml posts a short player-facing note to a Discord channel. The note is not generated. It is the ## Update section of the merged pull request's description, written by whoever opened the PR and carried across verbatim:
## Update
Booster packs no longer draw flagged tokens, so every card in a pack is one you can actually trade.
One to three sentences of plain English, addressed to a player rather than to a reviewer. Claude Code's attribution line and any Co-Authored-By: trailers are stripped before posting. A pull request with no ## Update section falls back to its title, so a merge never blocks on prose and the workflow never invents any.
Setting this up is a one-time job for the operator. In Discord, open Server Settings > Integrations > Webhooks, create a webhook pointed at the updates channel, and copy its URL. Add that URL to the repository as a secret named DISCORD_UPDATES_WEBHOOK under Settings > Secrets and variables > Actions. Until the secret exists the workflow logs that it has nowhere to post and exits green, so a merge never turns CI red over a webhook nobody has created yet.
Preview a post without sending it, with a GitHub token in the environment (export GITHUB_TOKEN=$(gh auth token)):
npm run changelog -- --pr 87 --dry-run # print the embed JSON for one pull request
npm run changelog -- --sha <sha> --dry-run
The workflow also accepts a pr number from the Actions tab, for re-posting by hand. It keeps no record of what it has already sent, so re-running it on the same commit posts a second message.
Install the dependencies and start the development server:
npm install
npm run dev
Use npm, not pnpm or yarn -- the repo tracks package-lock.json, and pnpm install will fail on its build-script approval gate for transitive dependencies.
Open http://localhost:3000 in your browser.
The app includes public defaults for its OBJKT GraphQL endpoint and Tezos mainnet RPC. You can override them in .env.local when needed:
NEXT_PUBLIC_OBJKT_API_URL=https://data.objkt.com/v3/graphql
NEXT_PUBLIC_TEZOS_RPC_URL=https://mainnet.api.tez.ie
npm test # Run the automated test suite
npm run lint # Check the code with ESLint
npm run calibrate:rarity # Grade 100 live packs and check the rarity pyramid (-- <n> for n packs)
npm run check:diversity # Measure 60 live packs: artist spread, listing age, rarity mix (-- <n> for n packs)
npm run migrate # Apply battle-system database migrations
npm run migrate:status # Print the migration plan without writing to the database
npm run denylist # Manage the booster-pack denylist (--list, --add, --remove)
npm run funnel # Report the launch bar and funnel (DATABASE_URL, else .env.local)
npm run changelog # Post a merged PR's Discord update (--pr <n> or --sha <sha>, --dry-run)
npm run check:link-preview -- <url> # Check what a link-preview crawler receives for a URL
npm run sample:og-artwork # Measure IPFS thumbnail sizes against the link-preview budget
npm run simulate # Run N ad-hoc battle simulations (see Battle system above)
npm run validate:tiebreak # The R14 tiebreak's own fixed acceptance run
npx tsc --noEmit # Type-check without emitting files
npm run build # Production build
The promo-video Claude Code skill in .claude/skills/promo-video/ makes a 20-second vertical promo from live captures of tzdeck.xyz: the pack, the demo battle, and TzDeck cards of one artist's own works, animated in code with a synthesized score. The cards come only from the artist asking for the promo, or one who agreed, so nobody's art is used without permission. Ask Claude Code for a TzDeck promo video, or run /promo-video. Its studio installs its own dependencies (Playwright, ffmpeg-static, the brand fonts) in a work directory outside the repo, so the app's package.json is untouched.
It runs on the shared engine of the demo-reel skill in .claude/skills/demo-reel/, which makes the same kind of reel for any web product: captures listed in a shots.json, a starter film that lays out for 9:16, 1:1 or 16:9, and the same renderer, score synthesizer and review loop. To use it in other projects, copy that folder into ~/.claude/skills/, then ask Claude Code for a demo reel or run /demo-reel.