diff --git a/.github/workflows/smokes.yml b/.github/workflows/smokes.yml index 8f474ebd..ef18fd52 100644 --- a/.github/workflows/smokes.yml +++ b/.github/workflows/smokes.yml @@ -35,7 +35,7 @@ jobs: - group: ride-2 smokes: achievements quality layout geese physics no-media controller-overlay clip-share - group: modes - smokes: slingshot slingshot-aim tourist host-room grandma props + smokes: slingshot slingshot-aim tourist host-room grandma props time-warp - group: controllers smokes: disconnect local-coop versus wardrobe - group: controllers-2 diff --git a/docs/test-script.md b/docs/test-script.md index 3c2b04db..c61e1d28 100644 --- a/docs/test-script.md +++ b/docs/test-script.md @@ -136,6 +136,8 @@ CI: `pr-preview.yml` runs PF-01, PF-06 (UI) and the lobby smokes on every PR. | SR-27 | Every end screen (game over, victory, tutorial complete, demo end, partner left, versus results) is the result-screen shell: a headline kit sheet, one action row in the shared order, the primary first and focused (#459) | A | `smoke:flows`, `smoke:versus`, `smoke:solo-extras`, `smoke:tutorial`, `smoke:layout` (fits at phone, desktop, TV; screenshots) | | SR-28 | First-time SOLO shows the tutorial intro before the countdown: START begins the tutorial (SKIP TUTORIAL on screen, Y / △ on a pad), SKIP / B / Esc skip to the level list (#459) | A | `smoke:tutorial`, `smoke:solo-extras` | | SR-29 | Pause menu (#459): Start / Esc / the quick menu's PAUSE mid-ride opens it and the ride (physics + race clock) stops; B / Start / Esc / RESUME continue; SETTINGS opens Options over it and B returns to the menu; View opens Help over it and B returns; RESTART rides again; END RIDE returns to the lobby; in versus P1's Start freezes both teams | A | `smoke:flows`, `smoke:versus`, `test/unit/race-manager` | +| SR-30 | Reward bikes (#611): Solo Flight (Penny-Farthing, player bots) then Time Warp close the chooser. Solo Flight: a locked silhouette with "n of N solo achievements"; earned = in colour, "Coming soon", not ridden yet. Time Warp: locked it is a dark silhouette with "n of N" over it (N = non-calendar achievements less 5) and the ride stays on the default bike; unlocked it shows in colour with its clock and flames running and is the pick; a solo ride loads `time_warp_tandem.glb` with both geese seated, the TimeWarpFX loop playing and the tandem's size; Show Riders off rides the plain frame; a paint preset rides the goose tandem again | A | `smoke:time-warp`, `test/unit/special-bikes` | +| SR-30h | SR-30 looks right: the silhouette reads as "something to earn", the flames are blue with orange pops now and then, the clock spins fast, the geese sit as on the tandem | M | — | ## 3. Ride feel (manual only) diff --git a/js/bike-model.js b/js/bike-model.js index ad9b0257..1312c49e 100644 --- a/js/bike-model.js +++ b/js/bike-model.js @@ -26,6 +26,12 @@ import { CrankMotion } from './crank-motion.js'; import { fallLean } from './crash-tumble.js'; import { shakeAt } from './crash-wobble.js'; import { batchSplit, hasParts } from './physics/bike-parts.js'; +import { isRigTwin } from './special-bikes.js'; + +// #611 · the goose tandem's length (claude-3d bike_rig_goosefit26.json length_m): a special bike on the same rig +// (the Time Warp, whose rockets and flames make it longer) is sized as this, so its geese stay the same size. +const GOOSE_TANDEM_LENGTH = 2.466; +const underFx = (o) => { for (let p = o; p; p = p.parent) if (/^Flame[BO]_/.test(p.name || '')) return true; return false; }; import { rebatch } from './bike-batch.js'; import { dracoDecoderPath } from './draco-path.js'; @@ -367,7 +373,7 @@ export class BikeModel { const preBox = new THREE.Box3().setFromObject(model, isGoose); const preSize = preBox.getSize(new THREE.Vector3()); const maxDim = Math.max(preSize.x, preSize.y, preSize.z); - const scale = targetLength / maxDim; + const scale = isGoose && isRigTwin(modelPath) ? targetLength / GOOSE_TANDEM_LENGTH : targetLength / maxDim; model.scale.setScalar(scale); // Face the riders model down the road. Applied before the bounds scan so @@ -384,7 +390,7 @@ export class BikeModel { const v = new THREE.Vector3(); model.traverse((child) => { - if (child.isMesh && child.geometry && child.geometry.attributes.position) { + if (child.isMesh && child.geometry && child.geometry.attributes.position && !underFx(child)) { // (not the flames) const pos = child.geometry.attributes.position; for (let i = 0; i < pos.count; i++) { v.fromBufferAttribute(pos, i); diff --git a/js/dressing-room.js b/js/dressing-room.js index ceffb950..f2f7012f 100644 --- a/js/dressing-room.js +++ b/js/dressing-room.js @@ -56,7 +56,7 @@ export class DressingRoom { * @param {() => void} [o.onClose] * @param {() => (import('./dressing-room-preview.js').DressingRoomPreview | null)} [o.makePreview] null = static cards (Low tier) * @param {() => Set} [o.lockedOutfits] outfits the store has not unlocked (#574) - * @param {(outfit: string) => string} [o.lockHint] why a locked outfit is locked (#611: "Player Bots reward: 63 of 79 …") + * @param {(outfit: string) => string} [o.lockHint] why a locked outfit is locked (#611: "Time Warp reward: 63 of 88 …") * @param {{ view: (n: number) => any, bind?: (n: number, padKey: string|null) => void, signIn: (n: number) => any, signOut: (n: number) => void, cancel: (n: number) => void, onChange?: (fn: (n: number) => void) => void }|null} [o.accounts] #612 the account row */ constructor({ sheet, roster, padKeyFor, controllerManager, onLookChanged = null, onClose = null, makePreview = null, lockedOutfits = null, lockHint = null, accounts = null }) { diff --git a/js/game.js b/js/game.js index 6db0f983..fc6a6b85 100644 --- a/js/game.js +++ b/js/game.js @@ -6,6 +6,7 @@ import * as THREE from 'three'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; import { colourwaysFor } from './goose-colourways.js'; import { PARTNER_LEFT_REASON } from './network-manager.js'; +import { bikeModelPathFor, isRigTwin, isSpecialBike } from './special-bikes.js'; import { isMobile, isTV, isLowPower, isAndroid, isIOS, EVT_COUNTDOWN, EVT_START, EVT_RESET, EVT_RESET_QUICK, EVT_GAMEOVER, EVT_CHECKPOINT, EVT_FINISH, EVT_RETURN_ROOM, MSG_PROFILE, TUNE, BALANCE_DEFAULTS, GUEST_NAME, BIKE_MODEL_PATH, CHOOSER_MODEL_PATH, getShowRiders, getRidersLight, getShowSolo, getGooseOutfit, getGooseColourway, setGooseColourway, getSeatLooks, getP1Look, getShowFps, getPhysicsFx, DAILY_BOARD_ENABLED, applyDifficulty, applySteeringFeel, snapshotTuningBase } from './config.js'; import { TUNING_BASE, setTuningBase, migrateSavedTuning, TUNING_SAVE_BASE_FLAG, tuningBaseFromSave, loadSteeringFeelPref, saveSteeringFeelPref, GYRO_MIN_SENSITIVITY, observedFullLeanDegrees } from './config.js'; // B2: un-feeled calibration base import { RaceManager, FIRST_SEGMENT_BONUS_S } from './race-manager.js'; @@ -1189,7 +1190,7 @@ class Game { this._restoreProceduralWorld(); // #400: never on a leftover tiles world this._hideTouristGoal(); this._leaveSlingMode(); - this.bike.applyPreset(this.lobby.selectedPreset); + this._applyBikeChoice(); this._lobbyBtn.textContent = 'LOBBY'; // Load saved tuning on every solo start @@ -1215,6 +1216,17 @@ class Game { this._setupStartHandler(); } + /** + * #611 · the chooser's pick for this ride: a paint preset on the goose tandem, or a special bike + * (its own GLB on the same rig, js/special-bikes.js) swapped in. Swapping is a no-op when it is + * already loaded; a locked pick never reaches here (the chooser falls back to the default). + */ + _applyBikeChoice() { + const key = this.lobby && this.lobby.selectedPresetKey; + if (!this.isTourist) this.bike.swapModel(bikeModelPathFor(this._showRiders, key), !this.versusRigs); + this.bike.applyPreset(isSpecialBike(key) ? null : this.lobby.selectedPreset); + } + _onMultiplayerReady(net, mode) { this.mode = mode; this.net = net; @@ -1222,7 +1234,7 @@ class Game { this._partnerLookKey = ''; this._partnerTouristReady = null; // #400: this ride's ready barrier starts empty this._lobbyBtn.textContent = 'ROOM'; - this.bike.applyPreset(this.lobby.selectedPreset); + this._applyBikeChoice(); // The lobby's InputManager may already hold motion permission (granted via // a user gesture in the lobby). Our game InputManager is a separate @@ -1639,7 +1651,7 @@ class Game { this._localP2Type = sourceType; this.balanceCtrlP2 = new BalanceController(inputP2); this._lobbyBtn.textContent = 'LOBBY'; - this.bike.applyPreset(this.lobby.selectedPreset); + this._applyBikeChoice(); this._loadSavedTuning(); // Shared pedal controller: P1 taps as 'captain', P2 taps as 'stoker'. @@ -1739,7 +1751,9 @@ class Game { scene: this.scene, world: this.world, lateralOffset: i === 0 ? -0.8 : 0.8, - bikeCloneSource: this.bike, // boot bike's GLB is loaded — clone, don't re-fetch + // boot bike's GLB is loaded — clone, don't re-fetch; a special bike (#611) has no team paint, so + // versus loads the goose tandem fresh instead + bikeCloneSource: isRigTwin(this.bike._modelPath) ? null : this.bike, }); rig.bike.applyPreset(presets[rig.color.presetKey] || null); rig.bike.setGooseLook(teamLooks(t)); @@ -6152,7 +6166,7 @@ class Game { // smoke:quality check audits every drawn material after a mid-ride switch). this.bike.swapModel( - on ? BIKE_MODEL_PATH : CHOOSER_MODEL_PATH, + bikeModelPathFor(on, this.lobby && this.lobby.selectedPresetKey), // #611: a special bike stays itself !this.versusRigs // live versus clones share the old model's geometry ); @@ -6219,7 +6233,7 @@ class Game { } /** Player 1's outfit (Settings): saved to their look, then the geese follow on the next frame. */ - /** #611 · reward outfits this device has not earned yet (the robot: player bots). */ + /** #611 · reward outfits this device has not earned yet (the robot: the Time Warp reward). */ _lockedOutfits() { return lockedOutfits(GOOSE_OUTFITS, this.achievements ? this.achievements.getEarnedIds() : []); } diff --git a/js/goose-outfits.js b/js/goose-outfits.js index 5d99eb17..c6b02550 100644 --- a/js/goose-outfits.js +++ b/js/goose-outfits.js @@ -11,7 +11,7 @@ // fancy Victorian gent (swallowtail coat, top hat) + the lady in the red bustle dress // georgian 1770s gentleman (navy frock coat, tricorne) + a lady in a blue damask robe a l'anglaise // robot the Canada Goose bot's suit (chrome, white plates, blue lights, visor helmet) -- a REWARD outfit: -// unlocked with player bots (#611, js/rewards.js); the gander robot + the lady robot, both seats +// unlocked with the Time Warp reward (#611/#644, js/rewards.js); the gander robot + the lady robot, both seats const DIR = 'tandem-3d/goose/'; @@ -21,7 +21,7 @@ export const GOOSE_OUTFITS = { fancy: { label: 'Fancy', Captain: DIR + 'goose_captain_fancy.glb', Stoker: DIR + 'goose_stoker.glb' }, georgian: { label: 'Georgian', Captain: DIR + 'goose_captain_georgian.glb', Stoker: DIR + 'goose_stoker_georgian.glb' }, robot: { label: 'Robot', Captain: DIR + 'goose_captain_robot_m.glb', Stoker: DIR + 'goose_stoker_robot_f.glb', - reward: 'playerBots' }, + reward: 'timeWarp' }, }; /** The everyday outfits (no reward to unlock): versus team looks cycle through these. */ diff --git a/js/goose-rider-clips.js b/js/goose-rider-clips.js index d5121daf..dfdbcbaa 100644 --- a/js/goose-rider-clips.js +++ b/js/goose-rider-clips.js @@ -30,6 +30,7 @@ const UPPER = new Set(['Spine', 'Spine1', 'Spine2', 'Neck', 'Neck1', 'Neck2', 'N const LOWER_RE = /^(Left|Right)(UpLeg|Leg|Foot|ToeBase)$|^Cloth_/; const CRANK_RE = /^(Crank|Pedal)_/; // the bike's Pedal clip: cranks + pedals (+ their PedalPin sockets ride along) const WHEEL_RE = /^Wheel_/; +const FX_RE = /^(Clock|Flame)/; // #611 · the Time Warp bike's TimeWarpFX clip: clock hands + rocket flames /** Bone (node) name a three.js track targets: "LeftLeg.quaternion" -> "LeftLeg". */ export function trackBone(trackName) { @@ -46,11 +47,14 @@ export function isLowerBone(name) { return LOWER_RE.test(name); } /** The bike's crank / pedal bones (its Pedal clip) and wheel bones (its WheelSpin clip). */ export function isCrankBone(name) { return CRANK_RE.test(name); } export function isWheelBone(name) { return WHEEL_RE.test(name); } +/** A special bike's effect bones (the Time Warp's clock hands and flames: its TimeWarpFX clip). */ +export function isFxBone(name) { return FX_RE.test(name); } /** Which bones a clip may drive, by its name; null = not a clip of the goose tandem. */ export function boneFilterFor(clipName) { if (clipName === 'Pedal') return isCrankBone; if (clipName === 'WheelSpin') return isWheelBone; + if (clipName === 'TimeWarpFX') return isFxBone; if (/^Pedal_/.test(clipName)) return isLowerBone; if (/^(Ride|Lean_L|Lean_R)_/.test(clipName)) return isUpperBone; return null; diff --git a/js/goose-riders.js b/js/goose-riders.js index ff462ace..040f0ebd 100644 --- a/js/goose-riders.js +++ b/js/goose-riders.js @@ -33,6 +33,11 @@ export function disposeTree(root) { }); } +/** Seconds on the wall clock (the effect loop runs whether or not the bike moves). */ +function fxClock() { + return (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000; +} + function cutClip(clip) { const keep = boneFilterFor(clip.name); const c = clip.clone(); @@ -59,6 +64,8 @@ export class GooseRiders { // on one mixer average out (the cranks turned half as far as the geese's legs) this.pedal = byName('Pedal') ? scrub(this.bikeMixer, cutClip(byName('Pedal'))) : null; this.wheels = byName('WheelSpin') ? scrub(this.bikeMixer, cutClip(byName('WheelSpin'))) : null; + // #611 · a special bike's own loop (the Time Warp: clock hands + rocket flames), on the wall clock + this.fx = byName('TimeWarpFX') ? scrub(this.bikeMixer, cutClip(byName('TimeWarpFX'))) : null; this.wheelCircumference = 0; // world units; set by BikeModel once the model is scaled this.riderClips = {}; // seat -> { pedal, ride, leanL, leanR } (already cut to their bones) this.riders = {}; // seat -> { root, mixer, pedal, ride, leanL, leanR } @@ -69,7 +76,8 @@ export class GooseRiders { /** Measure the wheel in world units (call after the model is scaled and placed). */ measureWheel() { - const tyre = this.model.getObjectByName('Cylinder008_cycle_0') || this.model.getObjectByName('tyre_rear_low_cycle_0'); + const tyre = this.model.getObjectByName('Cylinder008_cycle_0') || this.model.getObjectByName('tyre_rear_low_cycle_0') + || this.model.getObjectByName('TW_Wheel_R'); // #611 · the Time Warp's rear wheel (one mesh, tyre outermost) if (!tyre) return; this.model.updateMatrixWorld(true); const size = new THREE.Box3().setFromObject(tyre, true).getSize(new THREE.Vector3()); @@ -204,6 +212,7 @@ export class GooseRiders { if (this.wheels && this.wheelCircumference > 0) { this.wheels.time = turnTime(2 * Math.PI * this._dist / this.wheelCircumference, this.wheels.getClip().duration); } + if (this.fx) this.fx.time = turnTime(2 * Math.PI * fxClock() / this.fx.getClip().duration, this.fx.getClip().duration); this.bikeMixer.update(0); for (const [seat, r] of Object.entries(this.riders)) { if (r.ragdoll) continue; // #484: a crash's rag doll owns this goose's bones (physics/crash-scene.js) diff --git a/js/lobby.js b/js/lobby.js index c25ccc72..5416214d 100644 --- a/js/lobby.js +++ b/js/lobby.js @@ -39,7 +39,9 @@ import * as THREE from 'three'; import { GOOSE_OUTFITS } from './goose-outfits.js'; -import { lockedOutfits, outfitLockHint } from './rewards.js'; +import { lockedOutfits, outfitLockHint, rewardProgress } from './rewards.js'; +import { SPECIAL_BIKE_KEYS, SPECIAL_BIKES, isSpecialBike } from './special-bikes.js'; +import { boneFilterFor, trackBone } from './goose-rider-clips.js'; import { PARTNER_LEFT_REASON } from './network-manager.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { InputManager, isSteamTwinPad } from './input-manager.js'; @@ -114,6 +116,8 @@ const BIKE_NAMES = { bike_christmas: 'Christmas Cruiser', bike_newyears: 'New Year Rider', bike_birthday: 'Birthday Express', + solo_flight: 'Solo Flight', // #611 · special bikes (their own models, js/special-bikes.js) + time_warp: 'Time Warp', }; const BIKE_COLORS = { @@ -127,6 +131,8 @@ const BIKE_COLORS = { bike_christmas: '#2eff46', bike_newyears: '#fbff00', bike_birthday: '#66ff00', + solo_flight: '#9fd8ff', + time_warp: '#f0b860', }; // Map bike preset keys to their per-bike Grandma's House achievement IDs @@ -8182,7 +8188,7 @@ export class Lobby { } catch (e) { console.warn('Failed to load holiday bike presets:', e); } - this._presetKeys = ['default', ...Object.keys(this._presetData)]; + this._presetKeys = ['default', ...Object.keys(this._presetData), ...SPECIAL_BIKE_KEYS]; // Setup mini 3D preview const canvas = document.getElementById('bike-preview-canvas'); @@ -8326,9 +8332,142 @@ export class Lobby { }, { passive: true }); } + /** Earned achievement ids (the AchievementManager when the lobby has it, else its store). */ + _earnedAchievementIds() { + if (this._achievements) return this._achievements.getEarnedIds(); + try { + const raw = localStorage.getItem('tandemonium_achievements'); + return raw ? JSON.parse(raw).map(a => a.id) : []; + } catch { return []; } + } + + /** + * #611 · a special bike's own model in the chooser, loaded the first time it is shown: sized like the + * tandem preview, its own loop (the Time Warp's TimeWarpFX) on a mixer, materials kept so a locked + * silhouette can be undone. + */ + _loadSpecialPreview(key, onReady) { + this._specialPreviews = this._specialPreviews || {}; + const have = this._specialPreviews[key]; + if (have) { if (have.model) onReady(have); return; } + const def = SPECIAL_BIKES[key]; + const sp = this._specialPreviews[key] = { model: null }; + new GLTFLoader().load(def.model, (gltf) => { + const model = gltf.scene; + const box = new THREE.Box3().setFromObject(model); + const size = box.getSize(new THREE.Vector3()); + model.scale.setScalar(3.0 / Math.max(size.x, size.y, size.z)); + model.updateMatrixWorld(true); + const b2 = new THREE.Box3().setFromObject(model); + const c = b2.getCenter(new THREE.Vector3()); + model.position.set(-c.x, -b2.min.y, -c.z); + const mats = []; + model.traverse((o) => { + if (!o.isMesh) return; + o.material = o.material.clone(); + o.castShadow = true; + mats.push([o.material, o.material.clone()]); + }); + const mixer = new THREE.AnimationMixer(model); + const clip = def.fx && gltf.animations.find(x => x.name === def.fx); + let fx = null; + if (clip) { + const cut = clip.clone(); + const keep = boneFilterFor(def.fx); + cut.tracks = cut.tracks.filter(t => keep(trackBone(t.name))); + fx = mixer.clipAction(cut); + fx.play(); + fx.paused = true; + } + model.visible = false; + if (this._previewPivot) this._previewPivot.add(model); + Object.assign(sp, { model, mats, mixer, fx, dark: false }); + onReady(sp); + }, undefined, (err) => console.warn(def.name + ' preview failed to load:', err)); + } + + /** Dark silhouette (locked) or its own colours (unlocked). */ + _setSpecialDark(sp, dark) { + if (!sp || !sp.model || sp.dark === dark) return; + sp.dark = dark; + for (const [m, orig] of sp.mats) { + if (dark) { + if (m.color) m.color.set(0x050505); + if (m.emissive) m.emissive.set(0x000000); + if (m.map) m.map = null; + m.metalness = 0; m.roughness = 1; + } else { + m.copy(orig); + } + m.needsUpdate = true; + } + if (dark && sp.fx) { sp.fx.time = 0; sp.mixer.update(0); } // (the orange pops tucked away) + } + + /** Hide every special bike's preview model (a paint preset or another special bike is showing). */ + _hideSpecialPreviews(exceptKey = null) { + for (const [k, sp] of Object.entries(this._specialPreviews || {})) if (k !== exceptKey && sp.model) sp.model.visible = false; + } + + /** + * #611 · a special bike in the chooser: its own model; locked = a dark silhouette with "n of N" over it; + * earned = its colours (and its loop); a bike not rideable yet says so and leaves the ride on the default. + */ + _applySpecialToPreview(key, nameEl) { + const def = SPECIAL_BIKES[key]; + const prog = rewardProgress(def.reward, this._earnedAchievementIds()); + const locked = !prog.unlocked; + if (this._previewModel) this._previewModel.visible = false; + this._hideSpecialPreviews(key); + this._loadSpecialPreview(key, (sp) => { + if (this._presetKeys[this._presetIndex] !== key) return; // moved on while it loaded + sp.model.visible = true; + this._setSpecialDark(sp, locked); + }); + let hintEl = document.getElementById('bike-hint'); + if (!hintEl) { + hintEl = document.createElement('div'); + hintEl.id = 'bike-hint'; + hintEl.style.cssText = 'font-size:10px;color:rgba(255,255,255,0.5);margin-top:2px;text-align:center;'; + nameEl.parentNode.insertBefore(hintEl, nameEl.nextSibling); + } + this.selectedPreset = null; + hintEl.dataset.special = key; + if (locked) { + this.selectedPresetKey = 'default'; + nameEl.style.color = 'rgba(255,255,255,0.3)'; + nameEl.textContent = '\uD83D\uDD12 ' + def.name; + hintEl.innerHTML = `${prog.have} of ${prog.need}` + + `
${def.unit}`; + hintEl.title = def.reward === 'timeWarp' + ? `${def.name}: any ${prog.need} of the ${prog.total} achievements that don't need real days` + : `${def.name}: any ${prog.need} of the ${prog.total} solo achievements (player bots)`; + hintEl.style.display = ''; + return; + } + nameEl.style.color = def.color; + nameEl.textContent = def.icon + ' ' + def.name; + if (!def.rideable) { + this.selectedPresetKey = 'default'; + hintEl.innerHTML = 'Coming soon'; + hintEl.title = `${def.name} is earned; riding it comes in a later update`; + hintEl.style.display = ''; + return; + } + this.selectedPresetKey = key; + hintEl.style.display = 'none'; + hintEl.title = ''; + delete hintEl.dataset.special; + analytics.trackEvent('bike_color_change', { preset_key: key }); + } + _applyPresetToPreview() { const key = this._presetKeys[this._presetIndex]; const nameEl = document.getElementById('bike-name'); + if (isSpecialBike(key)) { this._applySpecialToPreview(key, nameEl); return; } + if (this._previewModel) this._previewModel.visible = true; + this._hideSpecialPreviews(); + { const h = document.getElementById('bike-hint'); if (h) delete h.dataset.special; } const locked = HOLIDAY_BIKES[key] && !this._isHolidayUnlocked(key); nameEl.style.color = locked ? 'rgba(255,255,255,0.3)' : (BIKE_COLORS[key] || 'rgba(68, 255, 102, 0.7)'); @@ -8458,6 +8597,12 @@ export class Lobby { if (this._previewPivot) { this._previewPivot.rotation.y += 0.008 * step; } + // #611 · the Time Warp's clock and flames run in the chooser too (not while it's a locked silhouette) + for (const sp of Object.values(this._specialPreviews || {})) { + if (!sp.model || !sp.model.visible || !sp.fx || sp.dark) continue; + sp.fx.time = (performance.now() / 1000) % sp.fx.getClip().duration; + sp.mixer.update(0); + } this._previewRenderer.render(this._previewScene, this._previewCamera); }; animate(); diff --git a/js/rewards.js b/js/rewards.js index 212e8d59..5c92c501 100644 --- a/js/rewards.js +++ b/js/rewards.js @@ -4,9 +4,10 @@ // // Pete, 2026-11-07 (#611): the unlock ladder. // Solo Flight bike -> "player bots" : any 80 of the 81 SOLO achievements (all solo but one) -// Time Warp bike -> time warp mode : any 90 of the 95 non-calendar achievements +// Time Warp bike -> time warp mode : any 90 of the 95 non-calendar achievements (timeWarpProgress) // Gold Standard bike : all 100 -// The robot outfit (the Canada Goose bot's suit) comes with the player-bots reward. +// The robot outfit (the Canada Goose bot's suit) comes with the Time Warp reward (Pete, 2026-11-07, #644; +// it was the player-bots reward until then). // // Groups are read from the definitions, so the counts follow the achievement swap (#608) on their own: // calendar -- needs real days: a `unit: 'days'` counter (days_3, days_7, launch_week, ...) or a Daily Ride @@ -53,27 +54,51 @@ export function playerBotsProgress(earnedIds, defs = ACHIEVEMENTS) { return { have, need, total, unlocked: have >= need }; } +/** + * #611 · progress toward the Time Warp bike: any 90 of the 95 non-calendar achievements (the slack of 5 can + * come from either the solo or the multiplayer ones). Counts follow the definitions (#608 swap). + * @param {Iterable} earnedIds + * @returns {{have: number, need: number, total: number, unlocked: boolean}} + */ +export function timeWarpProgress(earnedIds, defs = ACHIEVEMENTS) { + const pool = new Set(defs.filter(d => !isCalendarAchievement(d)).map(d => d.id)); + let have = 0; + for (const id of new Set(earnedIds || [])) if (pool.has(id)) have++; + const total = pool.size; + const need = Math.max(0, total - TIME_WARP_SLACK); + return { have, need, total, unlocked: have >= need }; +} + +/** Non-calendar achievements the Time Warp bike may be short of (Pete: 90 of 95). */ +export const TIME_WARP_SLACK = 5; + /** * Outfits a player has not unlocked yet (the Dressing Room / Settings hook, #574). Today only the - * reward outfits: `reward: 'playerBots'` in js/goose-outfits.js. + * reward outfits: `reward: 'timeWarp'` (or 'playerBots') in js/goose-outfits.js. * @param {Record} outfits GOOSE_OUTFITS * @param {Iterable} earnedIds */ export function lockedOutfits(outfits, earnedIds, defs = ACHIEVEMENTS) { const locked = new Set(); - let bots = null; for (const [key, o] of Object.entries(outfits)) { - if (o.reward === 'playerBots') { - bots = bots || playerBotsProgress(earnedIds, defs); - if (!bots.unlocked) locked.add(key); - } + const p = rewardProgress(o.reward, earnedIds, defs); + if (p && !p.unlocked) locked.add(key); } return locked; } -/** The line a locked reward outfit shows ("Player Bots reward: 63 of 79 solo achievements"). */ +/** Progress toward a reward by its key ('timeWarp' | 'playerBots'); null = not a reward. */ +export function rewardProgress(reward, earnedIds, defs = ACHIEVEMENTS) { + if (reward === 'timeWarp') return timeWarpProgress(earnedIds, defs); + if (reward === 'playerBots') return playerBotsProgress(earnedIds, defs); + return null; +} + +/** The line a locked reward outfit shows ("Time Warp reward: 63 of 88 achievements"). */ export function outfitLockHint(outfit, earnedIds, defs = ACHIEVEMENTS) { - if (!outfit || outfit.reward !== 'playerBots') return 'Coming to the store'; - const p = playerBotsProgress(earnedIds, defs); - return `Player Bots reward: ${p.have} of ${p.need} solo achievements`; + const p = outfit && rewardProgress(outfit.reward, earnedIds, defs); + if (!p) return 'Coming to the store'; + return outfit.reward === 'timeWarp' + ? `Time Warp reward: ${p.have} of ${p.need} achievements` + : `Player Bots reward: ${p.have} of ${p.need} solo achievements`; } diff --git a/js/special-bikes.js b/js/special-bikes.js new file mode 100644 index 00000000..1d58a9fe --- /dev/null +++ b/js/special-bikes.js @@ -0,0 +1,56 @@ +// ============================================================ +// SPECIAL BIKES — reward bikes that replace the whole model, not just its paint (#611) +// ============================================================ +// +// The bike chooser cycles paint presets on the one goose tandem. A special bike is its own GLB +// built on the SAME rig (claude-3d make_time_warp.py on bike_rigged_goosefit26), so every goose +// rider, socket and Pedal / WheelSpin clip fits it unchanged. +// solo_flight the Penny-Farthing tandem (claude-3d make_pf_tandem.py), the player-bots reward: any 80 +// of the 81 solo achievements (rewards.js playerBotsProgress). In the chooser only for now: +// a locked silhouette, and "coming soon" once earned. Riding it (its own rig, Pedal_PF clips, +// the Canada Goose bot on the back) is #644. +// time_warp Pete's steampunk time-travel tandem: four rockets, a fast clock, blue flames with +// orange pops (its TimeWarpFX clip). Unlocks at any 90 of the 95 non-calendar +// achievements (rewards.js timeWarpProgress). +// It rides only with Show Riders on: the plain frame model (riders off) has no rig to swap onto. +// Versus keeps the paint presets (the team colours), so it rides the goose tandem. + +import { BIKE_MODEL_PATH, GOOSE_BIKE_MODEL_PATH, CHOOSER_MODEL_PATH } from './config.js'; + +export const TIME_WARP_BIKE_KEY = 'time_warp'; +export const TIME_WARP_BIKE_MODEL_PATH = 'tandem-3d/goose/time_warp_tandem.glb'; +export const SOLO_FLIGHT_BIKE_KEY = 'solo_flight'; +export const SOLO_FLIGHT_BIKE_MODEL_PATH = 'tandem-3d/goose/solo_flight_tandem.glb'; + +/** + * The special bikes, in carousel order (after the paint presets): the reward ladder (#611). + * reward rewards.js rewardProgress key unit what the "n of N" counts + * rideable false = shown in the chooser, not ridden yet ("coming soon" once earned) + * fx the bike's own looping clip, if any + */ +export const SPECIAL_BIKES = Object.freeze({ + [SOLO_FLIGHT_BIKE_KEY]: Object.freeze({ name: 'Solo Flight', model: SOLO_FLIGHT_BIKE_MODEL_PATH, reward: 'playerBots', + unit: 'solo achievements', rideable: false, icon: '\uD83E\uDEB6', color: '#9fd8ff', fx: null }), + [TIME_WARP_BIKE_KEY]: Object.freeze({ name: 'Time Warp', model: TIME_WARP_BIKE_MODEL_PATH, reward: 'timeWarp', + unit: 'achievements', rideable: true, icon: '\u23F3', color: '#f0b860', fx: 'TimeWarpFX' }), +}); + +/** Chooser keys of the special bikes, in carousel order. */ +export const SPECIAL_BIKE_KEYS = Object.freeze(Object.keys(SPECIAL_BIKES)); + +export function isSpecialBike(key) { return SPECIAL_BIKE_KEYS.includes(key); } + +/** + * The GLB the ride loads for a chooser pick. + * @param {boolean} showRiders the Show Riders setting + * @param {string|null} bikeKey the chooser's selectedPresetKey + * @param {string} [ridersPath] BIKE_MODEL_PATH (the goose tandem, or ?riders=fused) + */ +export function bikeModelPathFor(showRiders, bikeKey, ridersPath = BIKE_MODEL_PATH) { + if (!showRiders) return CHOOSER_MODEL_PATH; + if (bikeKey === TIME_WARP_BIKE_KEY && ridersPath === GOOSE_BIKE_MODEL_PATH) return TIME_WARP_BIKE_MODEL_PATH; + return ridersPath; +} + +/** Is this the goose tandem or a special bike on its rig (sized like the tandem, see bike-model.js)? */ +export function isRigTwin(modelPath) { return modelPath === TIME_WARP_BIKE_MODEL_PATH; } diff --git a/package.json b/package.json index e7163a43..6906f3aa 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "smoke:local-coop": "node scripts/smoke-local-coop.mjs", "smoke:versus": "node scripts/smoke-versus.mjs", "smoke:wardrobe": "node scripts/smoke-wardrobe.mjs", + "smoke:time-warp": "node scripts/smoke-time-warp.mjs", "smoke:webhid": "node scripts/smoke-webhid.mjs", "smoke:flows": "node scripts/smoke-flows.mjs", "smoke:steam-input": "node scripts/smoke-steam-input.mjs", diff --git a/scripts/run-smokes.mjs b/scripts/run-smokes.mjs index c48a6de8..62af63fb 100644 --- a/scripts/run-smokes.mjs +++ b/scripts/run-smokes.mjs @@ -22,7 +22,7 @@ export const SMOKES = [ 'slingshot', 'slingshot-aim', 'tourist', 'grandma', 'props', 'host-room', 'disconnect', 'local-coop', 'versus', 'wardrobe', 'webhid', 'flows', 'steam-input', 'input', 'solo-extras', 'options', 'lobby-layout', 'tv-nav', 'online', 'phone-pad', 'phone-coop', 'phone-multi', - 'controller-overlay', 'fit', 'clip-share', 'tv-sizes', 'ach-detail', 'tv-playtest', 'rejoin', 'seat-churn', + 'controller-overlay', 'fit', 'clip-share', 'tv-sizes', 'ach-detail', 'tv-playtest', 'rejoin', 'seat-churn', 'time-warp', ]; const argv = process.argv.slice(2); diff --git a/scripts/smoke-time-warp.mjs b/scripts/smoke-time-warp.mjs new file mode 100644 index 00000000..c7096881 --- /dev/null +++ b/scripts/smoke-time-warp.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +// smoke:time-warp · #611 the Time Warp bike: a special bike in the chooser, its own model on the tandem's rig. +// +// TW-01 the chooser's last entry is the Time Warp: locked it shows a dark silhouette with "n of N" over it +// (N = the non-calendar achievements less 5: 90 once the #608 swap lands), +// and picking it leaves the ride on the default bike +// TW-02 with all non-calendar achievements but 5 it unlocks: its colours come back, its clock and flames +// run in the preview, and it becomes the pick +// TW-03 a solo ride loads time_warp_tandem.glb: both geese seated on its sockets, the TimeWarpFX loop playing +// (cut to the clock + flame bones), the wheel measured, and sized exactly like the goose tandem +// TW-04 Show Riders off rides the plain frame; on again brings the Time Warp back +// TW-05 picking a paint preset again rides the goose tandem +// SF-01 Solo Flight (the Penny-Farthing, player bots) sits before it: a locked silhouette with "n of N solo +// achievements"; once earned it shows in colour as "Coming soon" and the ride stays on the default +import { startHarness, sleep } from './lib/smoke-harness.mjs'; + +const h = await startHarness({ port: 8974, outDir: 'tandemonium-time-warp' }); +const { check } = h; +const until = (page, fn, arg, timeout = 90000) => page.waitForFunction(fn, { timeout }, arg).then(() => true, () => false); +const page = await h.gamePage({ storage: { tandemonium_tutorial_done: 'smoke', tandemonium_show_riders: 'on' } }); +await until(page, () => window._game.lobby._presetKeys.length > 1 && !!window._game.lobby._previewModel); + +const chooser = () => page.evaluate(() => { + const l = window._game.lobby; + const sp = (l._specialPreviews || {}).time_warp; + const hint = document.getElementById('bike-hint'); + return { + key: l._presetKeys[l._presetIndex], picked: l.selectedPresetKey, + name: document.getElementById('bike-name').textContent, + hint: hint && hint.style.display !== 'none' ? hint.textContent.replace(/\s+/g, ' ').trim() : '', + tandemShown: !!l._previewModel && l._previewModel.visible, + twShown: !!(sp && sp.model && sp.model.visible), dark: !!(sp && sp.dark), fxTime: sp && sp.fx ? +sp.fx.time.toFixed(3) : null, + }; +}); + +console.log('TW-01 locked: the silhouette and its count'); +await page.evaluate(() => { const l = window._game.lobby; l._presetIndex = l._presetKeys.indexOf('time_warp'); l._applyPresetToPreview(); }); +await until(page, () => { const sp = (window._game.lobby._specialPreviews || {}).time_warp; return !!(sp && sp.model && sp.model.visible); }); +let c = await chooser(); +check(c.key === 'time_warp' && /Time Warp/.test(c.name) && c.name.startsWith('🔒'), 'TW-01: the last chooser entry is the locked Time Warp', c); +check(await page.evaluate(() => window._game.lobby._presetKeys.slice(-2).join(',')) === 'solo_flight,time_warp', 'TW-01: Solo Flight then Time Warp close the chooser (the reward ladder)'); + +console.log('SF-01 Solo Flight: locked silhouette, "n of N solo achievements"; earned = coming soon, not ridden yet'); +const sf = async () => { + await page.evaluate(() => { const l = window._game.lobby; l._presetIndex = l._presetKeys.indexOf('solo_flight'); l._applyPresetToPreview(); }); + await until(page, () => { const sp = (window._game.lobby._specialPreviews || {}).solo_flight; return !!(sp && sp.model && sp.model.visible); }); + return page.evaluate(() => { + const l = window._game.lobby, sp = l._specialPreviews.solo_flight, h = document.getElementById('bike-hint'); + return { name: document.getElementById('bike-name').textContent, hint: h && h.style.display !== 'none' ? h.textContent.replace(/\s+/g, ' ').trim() : '', + dark: sp.dark, picked: l.selectedPresetKey, tandem: l._previewModel.visible, + others: Object.entries(l._specialPreviews).filter(([k, v]) => k !== 'solo_flight' && v.model && v.model.visible).map(([k]) => k) }; + }); +}; +let s1 = await sf(); +const sfNeed = await page.evaluate(async () => (await import('/js/rewards.js')).playerBotsProgress([]).need); +check(s1.name.startsWith('\uD83D\uDD12') && s1.name.includes('Solo Flight') && s1.dark && s1.picked === 'default', 'SF-01: Solo Flight is a locked dark silhouette; the ride stays on the default', s1); +check(s1.hint.replace(/\s+/g, '') === `0of${sfNeed}soloachievements`, 'SF-01: "n of N solo achievements" over it (N = solo less 1: 80 once #608 lands)', [s1.hint, sfNeed]); +check(s1.others.length === 0 && !s1.tandem, 'SF-01: only its own model shows', s1); +await page.evaluate(async () => { + const { soloAchievementIds } = await import('/js/rewards.js'); + const ach = window._game.lobby._achievements; + window.__sfBackup = new Map(ach._earned); + for (const id of soloAchievementIds().slice(1)) ach._earned.set(id, { id, earnedAt: 1 }); +}); +s1 = await sf(); +check(!s1.dark && /Coming soon/.test(s1.hint) && s1.picked === 'default', 'SF-01: earned, it shows in colour as coming soon and is not ridden yet', s1); +await h.shot(page, 'sf01-coming-soon'); +await page.evaluate(() => { window._game.lobby._achievements._earned = window.__sfBackup; }); +await page.evaluate(() => { const l = window._game.lobby; l._presetIndex = l._presetKeys.indexOf('time_warp'); l._applyPresetToPreview(); }); +const need = await page.evaluate(async () => (await import('/js/rewards.js')).timeWarpProgress([]).need); +check(c.hint.replace(/\s+/g, '') === `0of${need}achievements`, 'TW-01: "n of N" over the silhouette (N = non-calendar less 5)', [c.hint, need]); +check(c.twShown && !c.tandemShown && c.dark, 'TW-01: its own model, dark', c); +check(c.picked === 'default', 'TW-01: a locked Time Warp leaves the ride on the default bike', c.picked); +await h.shot(page, 'tw01-locked'); + +console.log('TW-02 unlocked with all non-calendar achievements but 5'); +await page.evaluate(async () => { + const { isCalendarAchievement } = await import('/js/rewards.js'); + const { ACHIEVEMENTS } = await import('/js/achievement-defs.js'); + const ach = window._game.lobby._achievements; + for (const a of ACHIEVEMENTS.filter(d => !isCalendarAchievement(d)).slice(5)) ach._earned.set(a.id, { id: a.id, earnedAt: 1 }); + window._game.lobby._applyPresetToPreview(); +}); +c = await chooser(); +check(c.picked === 'time_warp' && c.name.includes('Time Warp') && !c.name.startsWith('🔒') && !c.hint, 'TW-02: unlocked, it is the pick', c); +check(c.twShown && !c.dark, 'TW-02: in its own colours', c); +await sleep(600); +const c2 = await chooser(); +check(c.fxTime !== null && c2.fxTime !== c.fxTime, 'TW-02: its clock and flames run in the preview', [c.fxTime, c2.fxTime]); +await h.shot(page, 'tw02-unlocked'); + +console.log('TW-03 a solo ride on the Time Warp'); +await page.evaluate(() => window._game._onSolo()); +const loaded = await until(page, () => { const b = window._game.bike; return b._modelPath.endsWith('time_warp_tandem.glb') && b.modelLoaded && b.goose && Object.keys(b.goose.riders).length === 2; }, null, 180000); +const ride = await page.evaluate(() => { + const b = window._game.bike, g = b.goose; + const model = b.group.children[0]; + const tracks = g && g.fx ? [...new Set(g.fx.getClip().tracks.map(t => t.name.slice(0, t.name.lastIndexOf('.'))))] : []; + const t0 = g && g.fx ? g.fx.time : null; + return { path: b._modelPath, seats: g ? Object.fromEntries(Object.entries(g.riders).map(([s, r]) => [s, r.root.parent && r.root.parent.name])) : null, + fx: !!(g && g.fx), fxBones: tracks, t0, wheel: g ? +g.wheelCircumference.toFixed(3) : 0, scale: model ? +model.scale.x.toFixed(4) : 0 }; +}); +check(loaded && ride.seats && ride.seats.Captain === 'Seat_Captain' && ride.seats.Stoker === 'Seat_Stoker', 'TW-03: both geese seated on the Time Warp', ride); +check(ride.fx && ride.fxBones.length === 12 && ride.fxBones.every(n => /^(Clock|Flame)/.test(n)), 'TW-03: TimeWarpFX bound, cut to the clock + flame bones', ride.fxBones); +await sleep(700); +const t1 = await page.evaluate(() => { const g = window._game.bike.goose; g.update(); return g.fx.time; }); +check(t1 !== ride.t0, 'TW-03: the effects run on the wall clock', [ride.t0, t1]); +check(ride.wheel > 3.5 && ride.wheel < 4.0, 'TW-03: the rear wheel measured (wheels turn with the distance)', ride.wheel); +check(Math.abs(ride.scale - 4.4 / 2.466) < 0.002, 'TW-03: sized like the goose tandem (same geese)', ride.scale); +await h.shot(page, 'tw03-ride'); + +console.log('TW-04 Show Riders off / on'); +// (the Options button waits for the lobby mid-ride; _applyShowRiders is what it runs there) +await page.evaluate(() => window._game._applyShowRiders(false)); +check(await until(page, () => window._game.bike._modelPath.endsWith('tandem_bicycle.glb')), 'TW-04: riders off rides the plain frame', await page.evaluate(() => window._game.bike._modelPath)); +await page.evaluate(() => window._game._applyShowRiders(true)); +check(await until(page, () => window._game.bike._modelPath.endsWith('time_warp_tandem.glb')), 'TW-04: riders on again brings the Time Warp back', await page.evaluate(() => window._game.bike._modelPath)); + +console.log('TW-05 back to a paint preset'); +await page.evaluate(() => { const l = window._game.lobby; l._presetIndex = l._presetKeys.indexOf('bike_red'); l._applyPresetToPreview(); window._game._applyBikeChoice(); }); +check(await until(page, () => window._game.bike._modelPath.endsWith('goose_tandem.glb')), 'TW-05: a paint preset rides the goose tandem again', await page.evaluate(() => window._game.bike._modelPath)); +await h.finish(); diff --git a/scripts/smoke-wardrobe.mjs b/scripts/smoke-wardrobe.mjs index d4f5073c..296c9d3b 100644 --- a/scripts/smoke-wardrobe.mjs +++ b/scripts/smoke-wardrobe.mjs @@ -140,23 +140,24 @@ const buzzN = await page.evaluate(() => window._game.lobby._room.cards.get(2).bu check(buzz2 && buzzN === 2, 'WD-03: two quick light picks buzz once more, not twice (debounced)', buzzN); const noteGone = await until(page, () => { const h = document.querySelector('#dr-cards .dr-card[data-n="2"] .dr-hint'); return !!h && h.textContent === ''; }, null, 6000); check(noteGone, '#623: "Buzzing Player 2\'s controller…" clears once the buzz is over', await page.evaluate(() => document.querySelector('#dr-cards .dr-card[data-n="2"] .dr-hint')?.textContent)); -// #611 · the robot outfit: locked behind the player-bots reward (all solo achievements but one) +// #611 · the robot outfit: locked behind the Time Warp reward (any non-calendar achievements but 5, #644) const robot = await page.evaluate(() => { const b = document.querySelector('#dr-cards .dr-card[data-n="2"] button[data-pick="outfit"][data-val="robot"]'); return b ? { locked: b.dataset.locked === '1', title: b.title } : null; }); -check(!!robot && robot.locked && /^Player Bots reward: \d+ of \d+ solo achievements$/.test(robot.title), 'WD-03r: the Robot outfit shows locked with its player-bots progress', robot); +check(!!robot && robot.locked && /^Time Warp reward: \d+ of \d+ achievements$/.test(robot.title), 'WD-03r: the Robot outfit shows locked with its Time Warp progress', robot); await cardClick(page, 2, 'button[data-pick="outfit"][data-val="robot"]'); const lockedPick = await page.evaluate(() => ({ outfit: window.__playerLooks.get(2).outfit, hint: document.querySelector('#dr-cards .dr-card[data-n="2"] .dr-hint')?.textContent })); -check(lockedPick.outfit === 'georgian' && /^Locked: Player Bots reward/.test(lockedPick.hint || ''), 'WD-03r: picking the locked Robot keeps the outfit and says why', lockedPick); +check(lockedPick.outfit === 'georgian' && /^Locked: Time Warp reward/.test(lockedPick.hint || ''), 'WD-03r: picking the locked Robot keeps the outfit and says why', lockedPick); await page.evaluate(async () => { - const { soloAchievementIds } = await import('/js/rewards.js'); + const { isCalendarAchievement } = await import('/js/rewards.js'); + const { ACHIEVEMENTS } = await import('/js/achievement-defs.js'); const ach = window._game.lobby._achievements; window.__earnedBackup = new Map(ach._earned); - for (const id of soloAchievementIds().slice(1)) ach._earned.set(id, { id, earnedAt: 1 }); + for (const a of ACHIEVEMENTS.filter(d => !isCalendarAchievement(d)).slice(5)) ach._earned.set(a.id, { id: a.id, earnedAt: 1 }); }); await cardClick(page, 2, 'button[data-pick="outfit"][data-val="robot"]'); -check(await until(page, () => window.__playerLooks.get(2).outfit === 'robot'), 'WD-03r: with all solo achievements but one, the Robot can be worn', await look(page, 2)); +check(await until(page, () => window.__playerLooks.get(2).outfit === 'robot'), 'WD-03r: with the Time Warp reward (all non-calendar but 5), the Robot can be worn', await look(page, 2)); await cardClick(page, 2, 'button[data-pick="outfit"][data-val="georgian"]'); await page.evaluate(() => { window._game.lobby._achievements._earned = window.__earnedBackup; }); check(await until(page, () => window.__playerLooks.get(2).outfit === 'georgian'), 'WD-03r: back to Georgian for the rest of the run', await look(page, 2)); diff --git a/tandem-3d/goose/solo_flight_tandem.glb b/tandem-3d/goose/solo_flight_tandem.glb new file mode 100644 index 00000000..41a185e8 Binary files /dev/null and b/tandem-3d/goose/solo_flight_tandem.glb differ diff --git a/tandem-3d/goose/time_warp_tandem.glb b/tandem-3d/goose/time_warp_tandem.glb new file mode 100644 index 00000000..a1f5acab Binary files /dev/null and b/tandem-3d/goose/time_warp_tandem.glb differ diff --git a/test/unit/goose-outfits.test.mjs b/test/unit/goose-outfits.test.mjs index 2caeee33..49587c00 100644 --- a/test/unit/goose-outfits.test.mjs +++ b/test/unit/goose-outfits.test.mjs @@ -9,7 +9,7 @@ test('four everyday outfits + the robot reward outfit; victorian is the default' assert.deepEqual(Object.keys(GOOSE_OUTFITS), ['victorian', 'cyclists', 'fancy', 'georgian', 'robot']); assert.deepEqual(Object.values(GOOSE_OUTFITS).map(o => o.label), ['Victorian', 'Cyclists', 'Fancy', 'Georgian', 'Robot']); assert.deepEqual(BASE_OUTFITS, ['victorian', 'cyclists', 'fancy', 'georgian']); - assert.equal(GOOSE_OUTFITS.robot.reward, 'playerBots'); + assert.equal(GOOSE_OUTFITS.robot.reward, 'timeWarp'); assert.equal(DEFAULT_GOOSE_OUTFIT, 'victorian'); }); diff --git a/test/unit/rewards.test.mjs b/test/unit/rewards.test.mjs index 5f6a2280..cac345cd 100644 --- a/test/unit/rewards.test.mjs +++ b/test/unit/rewards.test.mjs @@ -1,11 +1,11 @@ -// #611 · rewards: the player-bots unlock (all solo achievements but one) and the robot outfit lock. +// #611 · rewards: the player-bots unlock (all solo achievements but one) and the robot outfit lock (Time Warp, #644). import test from 'node:test'; import assert from 'node:assert/strict'; import { ACHIEVEMENTS } from '../../js/achievement-defs.js'; import { GOOSE_OUTFITS } from '../../js/goose-outfits.js'; import { isCalendarAchievement, isMultiplayerAchievement, isSoloAchievement, soloAchievementIds, - playerBotsProgress, lockedOutfits, outfitLockHint, + playerBotsProgress, lockedOutfits, outfitLockHint, timeWarpProgress, rewardProgress, } from '../../js/rewards.js'; const byId = (id) => ACHIEVEMENTS.find(a => a.id === id); @@ -37,10 +37,22 @@ test('player bots: all solo but one', () => { assert.equal(playerBotsProgress([...allButOne, ...allButOne]).have, solo.length - 1, 'duplicates count once'); }); -test('the robot outfit is locked until player bots, with a progress hint', () => { - const solo = soloAchievementIds(); +test('the robot outfit is locked until the Time Warp reward (any non-calendar achievements but 5), with a progress hint', () => { + const nonCal = ACHIEVEMENTS.filter(a => !isCalendarAchievement(a)).map(a => a.id); + const need = timeWarpProgress([]).need; assert.deepEqual([...lockedOutfits(GOOSE_OUTFITS, [])], ['robot']); - assert.deepEqual([...lockedOutfits(GOOSE_OUTFITS, solo.slice(1))], []); - assert.equal(outfitLockHint(GOOSE_OUTFITS.robot, solo.slice(0, 5)), 'Player Bots reward: 5 of ' + (solo.length - 1) + ' solo achievements'); + assert.deepEqual([...lockedOutfits(GOOSE_OUTFITS, soloAchievementIds().slice(1))], ['robot'], 'player bots alone no longer unlocks it'); + assert.deepEqual([...lockedOutfits(GOOSE_OUTFITS, nonCal.slice(6))], ['robot']); + assert.deepEqual([...lockedOutfits(GOOSE_OUTFITS, nonCal.slice(5))], []); + assert.equal(outfitLockHint(GOOSE_OUTFITS.robot, nonCal.slice(0, 5)), `Time Warp reward: 5 of ${need} achievements`); assert.equal(outfitLockHint(GOOSE_OUTFITS.victorian, []), 'Coming to the store'); + assert.equal(rewardProgress('nope', []), null); +}); + +test('a player-bots reward outfit (none today) still reads its own progress', () => { + const solo = soloAchievementIds(); + const bots = { bot: { reward: 'playerBots' } }; + assert.deepEqual([...lockedOutfits(bots, [])], ['bot']); + assert.deepEqual([...lockedOutfits(bots, solo.slice(1))], []); + assert.equal(outfitLockHint(bots.bot, solo.slice(0, 5)), 'Player Bots reward: 5 of ' + (solo.length - 1) + ' solo achievements'); }); diff --git a/test/unit/special-bikes.test.mjs b/test/unit/special-bikes.test.mjs new file mode 100644 index 00000000..8a53ed9c --- /dev/null +++ b/test/unit/special-bikes.test.mjs @@ -0,0 +1,58 @@ +// #611 · special bikes: the Time Warp unlock (any 90 of the 95 non-calendar achievements), which GLB a +// chooser pick rides, and its effect clip's bones. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { ACHIEVEMENTS } from '../../js/achievement-defs.js'; +import { timeWarpProgress, isCalendarAchievement, TIME_WARP_SLACK } from '../../js/rewards.js'; +import { bikeModelPathFor, TIME_WARP_BIKE_KEY, TIME_WARP_BIKE_MODEL_PATH, SOLO_FLIGHT_BIKE_KEY, SPECIAL_BIKES, SPECIAL_BIKE_KEYS, isSpecialBike, isRigTwin } from '../../js/special-bikes.js'; +import { GOOSE_BIKE_MODEL_PATH, CHOOSER_MODEL_PATH, FUSED_RIDERS_MODEL_PATH } from '../../js/config.js'; +import { boneFilterFor } from '../../js/goose-rider-clips.js'; + +const nonCal = ACHIEVEMENTS.filter(a => !isCalendarAchievement(a)).map(a => a.id); +const cal = ACHIEVEMENTS.filter(a => isCalendarAchievement(a)).map(a => a.id); + +test('Time Warp: any 90 of the 95 non-calendar achievements; calendar ones never count', () => { + const total = nonCal.length; + assert.equal(TIME_WARP_SLACK, 5); + assert.deepEqual(timeWarpProgress([]), { have: 0, need: total - 5, total, unlocked: false }); + assert.equal(timeWarpProgress(nonCal.slice(6)).unlocked, false, 'six short'); + assert.equal(timeWarpProgress(nonCal.slice(5)).unlocked, true, 'any five can be skipped'); + assert.equal(timeWarpProgress(nonCal.slice(0, -5)).unlocked, true, 'whichever five'); + assert.equal(timeWarpProgress([...nonCal.slice(6), ...cal]).unlocked, false, 'calendar ones do not count'); + assert.equal(timeWarpProgress([...nonCal.slice(5), ...nonCal.slice(5)]).have, total - 5, 'duplicates count once'); +}); + +test('the chooser pick picks the GLB: Time Warp only with Show Riders on the goose tandem', () => { + assert.deepEqual(SPECIAL_BIKE_KEYS, [SOLO_FLIGHT_BIKE_KEY, TIME_WARP_BIKE_KEY], 'the reward ladder: Solo Flight, then Time Warp'); + assert.equal(SPECIAL_BIKES.solo_flight.reward, 'playerBots'); + assert.equal(SPECIAL_BIKES.solo_flight.rideable, false, 'Solo Flight is chooser-only until #644'); + assert.equal(SPECIAL_BIKES.time_warp.reward, 'timeWarp'); + for (const d of Object.values(SPECIAL_BIKES)) assert.ok(fs.existsSync(d.model), d.model); + assert.equal(bikeModelPathFor(true, 'solo_flight', GOOSE_BIKE_MODEL_PATH), GOOSE_BIKE_MODEL_PATH, 'not ridden yet'); + assert.ok(isSpecialBike('time_warp') && !isSpecialBike('bike_red') && !isSpecialBike('default')); + assert.equal(bikeModelPathFor(true, 'time_warp', GOOSE_BIKE_MODEL_PATH), TIME_WARP_BIKE_MODEL_PATH); + assert.equal(bikeModelPathFor(true, 'bike_red', GOOSE_BIKE_MODEL_PATH), GOOSE_BIKE_MODEL_PATH); + assert.equal(bikeModelPathFor(true, null, GOOSE_BIKE_MODEL_PATH), GOOSE_BIKE_MODEL_PATH); + assert.equal(bikeModelPathFor(false, 'time_warp', GOOSE_BIKE_MODEL_PATH), CHOOSER_MODEL_PATH, 'riders off: the plain frame'); + assert.equal(bikeModelPathFor(true, 'time_warp', FUSED_RIDERS_MODEL_PATH), FUSED_RIDERS_MODEL_PATH, '?riders=fused has no rig twin'); + assert.ok(isRigTwin(TIME_WARP_BIKE_MODEL_PATH) && !isRigTwin(GOOSE_BIKE_MODEL_PATH)); + assert.ok(fs.existsSync(TIME_WARP_BIKE_MODEL_PATH), TIME_WARP_BIKE_MODEL_PATH); +}); + +test('TimeWarpFX drives only the clock hands and flames', () => { + const keep = boneFilterFor('TimeWarpFX'); + for (const b of ['ClockM_L', 'ClockH_R', 'FlameB_0', 'FlameO_3']) assert.ok(keep(b), b); + for (const b of ['Crank_F', 'Wheel_R', 'Pedal_FL', 'Seat_Captain', 'Steer', 'LeftLeg']) assert.ok(!keep(b), b); +}); + +test('the Time Warp GLB keeps the tandem rig and carries its effect clip', () => { + const b = fs.readFileSync(TIME_WARP_BIKE_MODEL_PATH); + const len = b.readUInt32LE(12); + const j = JSON.parse(b.subarray(20, 20 + len).toString('utf8')); + const names = new Set(j.nodes.map(n => n.name)); + for (const n of ['Bike_root', 'Steer', 'Wheel_F', 'Wheel_R', 'Crank_F', 'Crank_R', 'Seat_Captain', 'Seat_Stoker', + 'Grip_C_L', 'Grip_S_R', 'PedalPin_FL', 'PedalPin_RR', 'ClockM_L', 'FlameB_0', 'FlameO_3', + 'seat_cycle_0', 'seat.001_cycle_0', 'TW_Wheel_R']) assert.ok(names.has(n), n); + assert.deepEqual(j.animations.map(a => a.name).sort(), ['Pedal', 'TimeWarpFX', 'WheelSpin']); +});