759 lines
20 KiB
TypeScript
759 lines
20 KiB
TypeScript
/**
|
|
* Discovery Anchor Management
|
|
*
|
|
* Create, manage, and verify discovery anchors - the hidden or
|
|
* semi-hidden locations that players can discover using zkGPS proofs.
|
|
*/
|
|
|
|
import type {
|
|
DiscoveryAnchor,
|
|
AnchorType,
|
|
AnchorVisibility,
|
|
AnchorHint,
|
|
DiscoveryReward,
|
|
IoTRequirement,
|
|
SocialRequirement,
|
|
HintContent,
|
|
HintRevealCondition,
|
|
Discovery,
|
|
NavigationHint,
|
|
GameEvent,
|
|
GameEventListener,
|
|
} from './types';
|
|
import { TEMPERATURE_THRESHOLDS } from './types';
|
|
import type { GeohashCommitment, ProximityProof } from '../privacy/types';
|
|
import {
|
|
createCommitment,
|
|
verifyCommitment,
|
|
generateProximityProof,
|
|
verifyProximityProof,
|
|
} from '../privacy';
|
|
|
|
// =============================================================================
|
|
// Anchor Manager
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Configuration for anchor manager
|
|
*/
|
|
export interface AnchorManagerConfig {
|
|
/** Default precision required for discovery */
|
|
defaultPrecision: number;
|
|
|
|
/** Maximum hints per anchor */
|
|
maxHintsPerAnchor: number;
|
|
|
|
/** Allow IoT-free discoveries */
|
|
allowVirtualDiscoveries: boolean;
|
|
|
|
/** Minimum time between discoveries at same anchor */
|
|
cooldownSeconds: number;
|
|
}
|
|
|
|
/**
|
|
* Default configuration
|
|
*/
|
|
export const DEFAULT_ANCHOR_CONFIG: AnchorManagerConfig = {
|
|
defaultPrecision: 7, // ~76m accuracy
|
|
maxHintsPerAnchor: 10,
|
|
allowVirtualDiscoveries: true,
|
|
cooldownSeconds: 60,
|
|
};
|
|
|
|
/**
|
|
* Manages discovery anchors
|
|
*/
|
|
export class AnchorManager {
|
|
private config: AnchorManagerConfig;
|
|
private anchors: Map<string, DiscoveryAnchor> = new Map();
|
|
private discoveries: Map<string, Discovery[]> = new Map(); // anchorId -> discoveries
|
|
private listeners: Set<GameEventListener> = new Set();
|
|
|
|
constructor(config: Partial<AnchorManagerConfig> = {}) {
|
|
this.config = { ...DEFAULT_ANCHOR_CONFIG, ...config };
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Anchor Creation
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Create a new discovery anchor
|
|
*/
|
|
async createAnchor(params: {
|
|
name: string;
|
|
description: string;
|
|
type: AnchorType;
|
|
visibility: AnchorVisibility;
|
|
latitude: number;
|
|
longitude: number;
|
|
precision?: number;
|
|
creatorPubKey: string;
|
|
creatorPrivKey: string;
|
|
activeWindow?: DiscoveryAnchor['activeWindow'];
|
|
iotRequirements?: IoTRequirement[];
|
|
socialRequirements?: SocialRequirement;
|
|
rewards?: DiscoveryReward[];
|
|
hints?: AnchorHint[];
|
|
prerequisites?: string[];
|
|
metadata?: Record<string, unknown>;
|
|
}): Promise<DiscoveryAnchor> {
|
|
const id = `anchor-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
// Create zkGPS commitment for the location
|
|
const locationCommitment = await createCommitment(
|
|
params.latitude,
|
|
params.longitude,
|
|
12, // Full precision internally
|
|
params.creatorPubKey,
|
|
params.creatorPrivKey
|
|
);
|
|
|
|
const anchor: DiscoveryAnchor = {
|
|
id,
|
|
name: params.name,
|
|
description: params.description,
|
|
type: params.type,
|
|
visibility: params.visibility,
|
|
locationCommitment,
|
|
requiredPrecision: params.precision ?? this.config.defaultPrecision,
|
|
activeWindow: params.activeWindow,
|
|
iotRequirements: params.iotRequirements,
|
|
socialRequirements: params.socialRequirements,
|
|
rewards: params.rewards ?? [],
|
|
hints: params.hints ?? [],
|
|
prerequisites: params.prerequisites ?? [],
|
|
metadata: params.metadata ?? {},
|
|
creatorPubKey: params.creatorPubKey,
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
this.anchors.set(id, anchor);
|
|
this.discoveries.set(id, []);
|
|
|
|
this.emit({ type: 'anchor:created', anchor });
|
|
|
|
return anchor;
|
|
}
|
|
|
|
/**
|
|
* Add a hint to an anchor
|
|
*/
|
|
addHint(anchorId: string, hint: Omit<AnchorHint, 'id'>): AnchorHint | null {
|
|
const anchor = this.anchors.get(anchorId);
|
|
if (!anchor) return null;
|
|
|
|
if (anchor.hints.length >= this.config.maxHintsPerAnchor) {
|
|
return null;
|
|
}
|
|
|
|
const fullHint: AnchorHint = {
|
|
...hint,
|
|
id: `hint-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
};
|
|
|
|
anchor.hints.push(fullHint);
|
|
return fullHint;
|
|
}
|
|
|
|
/**
|
|
* Add rewards to an anchor
|
|
*/
|
|
addReward(anchorId: string, reward: DiscoveryReward): boolean {
|
|
const anchor = this.anchors.get(anchorId);
|
|
if (!anchor) return false;
|
|
|
|
anchor.rewards.push(reward);
|
|
return true;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Discovery Verification
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Attempt to discover an anchor
|
|
*/
|
|
async attemptDiscovery(params: {
|
|
anchorId: string;
|
|
playerPubKey: string;
|
|
playerPrivKey: string;
|
|
playerLatitude: number;
|
|
playerLongitude: number;
|
|
iotVerification?: Discovery['iotVerification'];
|
|
groupDiscovery?: Discovery['groupDiscovery'];
|
|
}): Promise<{ success: boolean; discovery?: Discovery; error?: string }> {
|
|
const anchor = this.anchors.get(params.anchorId);
|
|
if (!anchor) {
|
|
return { success: false, error: 'Anchor not found' };
|
|
}
|
|
|
|
// Check prerequisites
|
|
const prereqCheck = this.checkPrerequisites(anchor, params.playerPubKey);
|
|
if (!prereqCheck.met) {
|
|
return { success: false, error: `Missing prerequisites: ${prereqCheck.missing.join(', ')}` };
|
|
}
|
|
|
|
// Check time window
|
|
if (anchor.activeWindow) {
|
|
const now = new Date();
|
|
if (now < anchor.activeWindow.start || now > anchor.activeWindow.end) {
|
|
return { success: false, error: 'Anchor not active at this time' };
|
|
}
|
|
}
|
|
|
|
// Check IoT requirements
|
|
if (anchor.iotRequirements && anchor.iotRequirements.length > 0) {
|
|
if (!params.iotVerification) {
|
|
return { success: false, error: 'IoT verification required' };
|
|
}
|
|
const iotValid = this.verifyIoT(anchor.iotRequirements, params.iotVerification);
|
|
if (!iotValid) {
|
|
return { success: false, error: 'IoT verification failed' };
|
|
}
|
|
}
|
|
|
|
// Check social requirements
|
|
if (anchor.socialRequirements) {
|
|
if (!params.groupDiscovery) {
|
|
return { success: false, error: 'Group discovery required' };
|
|
}
|
|
const socialValid = this.verifySocialRequirements(
|
|
anchor.socialRequirements,
|
|
params.groupDiscovery
|
|
);
|
|
if (!socialValid.valid) {
|
|
return { success: false, error: socialValid.error };
|
|
}
|
|
}
|
|
|
|
// Generate proximity proof
|
|
const proximityProof = await generateProximityProof(
|
|
params.playerLatitude,
|
|
params.playerLongitude,
|
|
anchor.locationCommitment,
|
|
anchor.requiredPrecision,
|
|
params.playerPubKey,
|
|
params.playerPrivKey
|
|
);
|
|
|
|
// Verify proximity
|
|
const proofValid = await verifyProximityProof(
|
|
proximityProof,
|
|
anchor.locationCommitment,
|
|
params.playerPubKey
|
|
);
|
|
|
|
if (!proofValid) {
|
|
return { success: false, error: 'Not close enough to anchor' };
|
|
}
|
|
|
|
// Check cooldown
|
|
const existingDiscoveries = this.discoveries.get(params.anchorId) ?? [];
|
|
const playerDiscoveries = existingDiscoveries.filter(
|
|
(d) => d.playerPubKey === params.playerPubKey
|
|
);
|
|
if (playerDiscoveries.length > 0) {
|
|
const lastDiscovery = playerDiscoveries[playerDiscoveries.length - 1];
|
|
const timeSince = Date.now() - lastDiscovery.timestamp.getTime();
|
|
if (timeSince < this.config.cooldownSeconds * 1000) {
|
|
return { success: false, error: 'Discovery cooldown active' };
|
|
}
|
|
}
|
|
|
|
// Create discovery record
|
|
const isFirstFinder = existingDiscoveries.length === 0;
|
|
const discoveryOrder = existingDiscoveries.length + 1;
|
|
|
|
const discovery: Discovery = {
|
|
id: `discovery-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
anchorId: params.anchorId,
|
|
playerPubKey: params.playerPubKey,
|
|
proximityProof,
|
|
iotVerification: params.iotVerification,
|
|
groupDiscovery: params.groupDiscovery,
|
|
timestamp: new Date(),
|
|
isFirstFinder,
|
|
discoveryOrder,
|
|
rewardsClaimed: [],
|
|
playerSignature: await this.signDiscovery(params.playerPrivKey, params.anchorId),
|
|
};
|
|
|
|
existingDiscoveries.push(discovery);
|
|
this.discoveries.set(params.anchorId, existingDiscoveries);
|
|
|
|
this.emit({ type: 'anchor:discovered', discovery });
|
|
if (isFirstFinder) {
|
|
this.emit({ type: 'anchor:firstFind', discovery, rank: 1 });
|
|
}
|
|
|
|
return { success: true, discovery };
|
|
}
|
|
|
|
/**
|
|
* Check if prerequisites are met
|
|
*/
|
|
private checkPrerequisites(
|
|
anchor: DiscoveryAnchor,
|
|
playerPubKey: string
|
|
): { met: boolean; missing: string[] } {
|
|
const missing: string[] = [];
|
|
|
|
for (const prereqId of anchor.prerequisites) {
|
|
const prereqDiscoveries = this.discoveries.get(prereqId) ?? [];
|
|
const hasDiscovered = prereqDiscoveries.some((d) => d.playerPubKey === playerPubKey);
|
|
if (!hasDiscovered) {
|
|
missing.push(prereqId);
|
|
}
|
|
}
|
|
|
|
return { met: missing.length === 0, missing };
|
|
}
|
|
|
|
/**
|
|
* Verify IoT requirements
|
|
*/
|
|
private verifyIoT(
|
|
requirements: IoTRequirement[],
|
|
verification: Discovery['iotVerification']
|
|
): boolean {
|
|
if (!verification) return false;
|
|
|
|
for (const req of requirements) {
|
|
if (req.type !== verification.type) continue;
|
|
|
|
// Check challenge response if required
|
|
if (req.expectedResponseHash && verification.challengeResponse) {
|
|
// In real implementation, hash the response and compare
|
|
// For now, just check it exists
|
|
if (!verification.challengeResponse) return false;
|
|
}
|
|
|
|
// Check signal strength for BLE
|
|
if (req.type === 'ble' && req.minRssi !== undefined) {
|
|
if (!verification.rssi || verification.rssi < req.minRssi) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Verify social requirements
|
|
*/
|
|
private verifySocialRequirements(
|
|
requirements: SocialRequirement,
|
|
groupDiscovery: Discovery['groupDiscovery']
|
|
): { valid: boolean; error?: string } {
|
|
if (!groupDiscovery) {
|
|
return { valid: false, error: 'Group discovery data required' };
|
|
}
|
|
|
|
const playerCount = groupDiscovery.playerPubKeys.length;
|
|
|
|
if (playerCount < requirements.minPlayers) {
|
|
return {
|
|
valid: false,
|
|
error: `Need at least ${requirements.minPlayers} players, have ${playerCount}`,
|
|
};
|
|
}
|
|
|
|
if (requirements.maxPlayers && playerCount > requirements.maxPlayers) {
|
|
return {
|
|
valid: false,
|
|
error: `Maximum ${requirements.maxPlayers} players allowed`,
|
|
};
|
|
}
|
|
|
|
if (requirements.requiredPlayers) {
|
|
for (const required of requirements.requiredPlayers) {
|
|
if (!groupDiscovery.playerPubKeys.includes(required)) {
|
|
return { valid: false, error: `Required player not present: ${required}` };
|
|
}
|
|
}
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Sign a discovery
|
|
*/
|
|
private async signDiscovery(privKey: string, anchorId: string): Promise<string> {
|
|
// In real implementation, use proper signing
|
|
const message = `discovery:${anchorId}:${Date.now()}`;
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(message + privKey);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Navigation and Hints
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Get hot/cold navigation hint
|
|
*/
|
|
async getNavigationHint(
|
|
anchorId: string,
|
|
playerLatitude: number,
|
|
playerLongitude: number,
|
|
playerPrecision: number = 7
|
|
): Promise<NavigationHint | null> {
|
|
const anchor = this.anchors.get(anchorId);
|
|
if (!anchor) return null;
|
|
|
|
// Only provide hints for hinted or revealed anchors
|
|
if (anchor.visibility === 'hidden') return null;
|
|
|
|
// Calculate geohash difference
|
|
// In real implementation, compare player geohash with anchor geohash
|
|
// For now, simulate based on precision levels
|
|
|
|
// Get player's geohash at various precisions
|
|
const playerGeohash = this.latLongToGeohash(playerLatitude, playerLongitude, 12);
|
|
const anchorGeohash = anchor.locationCommitment.geohash;
|
|
|
|
// Find how many characters match
|
|
let matchingChars = 0;
|
|
for (let i = 0; i < Math.min(playerGeohash.length, anchorGeohash.length); i++) {
|
|
if (playerGeohash[i] === anchorGeohash[i]) {
|
|
matchingChars++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const geohashDiff = anchor.requiredPrecision - matchingChars;
|
|
|
|
// Calculate temperature
|
|
let temperature: number;
|
|
let description: NavigationHint['description'];
|
|
|
|
if (geohashDiff <= 0) {
|
|
temperature = 100;
|
|
description = 'burning';
|
|
} else if (geohashDiff <= TEMPERATURE_THRESHOLDS.burning.geohashDiff) {
|
|
temperature = 90;
|
|
description = 'burning';
|
|
} else if (geohashDiff <= TEMPERATURE_THRESHOLDS.hot.geohashDiff) {
|
|
temperature = 70;
|
|
description = 'hot';
|
|
} else if (geohashDiff <= TEMPERATURE_THRESHOLDS.warm.geohashDiff) {
|
|
temperature = 50;
|
|
description = 'warm';
|
|
} else if (geohashDiff <= TEMPERATURE_THRESHOLDS.cool.geohashDiff) {
|
|
temperature = 35;
|
|
description = 'cool';
|
|
} else if (geohashDiff <= TEMPERATURE_THRESHOLDS.cold.geohashDiff) {
|
|
temperature = 20;
|
|
description = 'cold';
|
|
} else {
|
|
temperature = 5;
|
|
description = 'freezing';
|
|
}
|
|
|
|
// Distance category
|
|
let distance: NavigationHint['distance'];
|
|
if (geohashDiff <= 0) distance = 'here';
|
|
else if (geohashDiff <= 1) distance = 'close';
|
|
else if (geohashDiff <= 2) distance = 'near';
|
|
else if (geohashDiff <= 4) distance = 'medium';
|
|
else distance = 'far';
|
|
|
|
return {
|
|
anchorId,
|
|
temperature,
|
|
description,
|
|
distance,
|
|
currentPrecision: matchingChars,
|
|
requiredPrecision: anchor.requiredPrecision,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get available hints for an anchor based on current conditions
|
|
*/
|
|
getAvailableHints(
|
|
anchorId: string,
|
|
playerPubKey: string,
|
|
playerPrecision: number,
|
|
groupSize: number = 1
|
|
): AnchorHint[] {
|
|
const anchor = this.anchors.get(anchorId);
|
|
if (!anchor) return [];
|
|
|
|
return anchor.hints.filter((hint) => {
|
|
return this.isHintRevealed(hint, playerPubKey, playerPrecision, groupSize);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if a hint should be revealed
|
|
*/
|
|
private isHintRevealed(
|
|
hint: AnchorHint,
|
|
playerPubKey: string,
|
|
playerPrecision: number,
|
|
groupSize: number
|
|
): boolean {
|
|
const condition = hint.revealCondition;
|
|
|
|
switch (condition.type) {
|
|
case 'immediate':
|
|
return true;
|
|
|
|
case 'proximity':
|
|
return playerPrecision >= condition.precision;
|
|
|
|
case 'time':
|
|
// Would need anchor creation time
|
|
return true;
|
|
|
|
case 'discovery':
|
|
const discoveries = this.discoveries.get(condition.anchorId) ?? [];
|
|
return discoveries.some((d) => d.playerPubKey === playerPubKey);
|
|
|
|
case 'social':
|
|
return groupSize >= condition.minPlayers;
|
|
|
|
case 'payment':
|
|
// Would need payment verification
|
|
return false;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert lat/long to geohash
|
|
* Simplified implementation - use a proper library in production
|
|
*/
|
|
private latLongToGeohash(lat: number, lon: number, precision: number): string {
|
|
const base32 = '0123456789bcdefghjkmnpqrstuvwxyz';
|
|
let minLat = -90, maxLat = 90;
|
|
let minLon = -180, maxLon = 180;
|
|
let hash = '';
|
|
let bit = 0;
|
|
let ch = 0;
|
|
let isLon = true;
|
|
|
|
while (hash.length < precision) {
|
|
if (isLon) {
|
|
const mid = (minLon + maxLon) / 2;
|
|
if (lon >= mid) {
|
|
ch = ch * 2 + 1;
|
|
minLon = mid;
|
|
} else {
|
|
ch = ch * 2;
|
|
maxLon = mid;
|
|
}
|
|
} else {
|
|
const mid = (minLat + maxLat) / 2;
|
|
if (lat >= mid) {
|
|
ch = ch * 2 + 1;
|
|
minLat = mid;
|
|
} else {
|
|
ch = ch * 2;
|
|
maxLat = mid;
|
|
}
|
|
}
|
|
|
|
isLon = !isLon;
|
|
bit++;
|
|
|
|
if (bit === 5) {
|
|
hash += base32[ch];
|
|
bit = 0;
|
|
ch = 0;
|
|
}
|
|
}
|
|
|
|
return hash;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Queries
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Get anchor by ID
|
|
*/
|
|
getAnchor(id: string): DiscoveryAnchor | undefined {
|
|
return this.anchors.get(id);
|
|
}
|
|
|
|
/**
|
|
* Get all anchors
|
|
*/
|
|
getAllAnchors(): DiscoveryAnchor[] {
|
|
return Array.from(this.anchors.values());
|
|
}
|
|
|
|
/**
|
|
* Get anchors by visibility
|
|
*/
|
|
getAnchorsByVisibility(visibility: AnchorVisibility): DiscoveryAnchor[] {
|
|
return Array.from(this.anchors.values()).filter((a) => a.visibility === visibility);
|
|
}
|
|
|
|
/**
|
|
* Get discoveries for an anchor
|
|
*/
|
|
getDiscoveries(anchorId: string): Discovery[] {
|
|
return this.discoveries.get(anchorId) ?? [];
|
|
}
|
|
|
|
/**
|
|
* Get player's discoveries
|
|
*/
|
|
getPlayerDiscoveries(playerPubKey: string): Discovery[] {
|
|
const all: Discovery[] = [];
|
|
for (const discoveries of this.discoveries.values()) {
|
|
all.push(...discoveries.filter((d) => d.playerPubKey === playerPubKey));
|
|
}
|
|
return all;
|
|
}
|
|
|
|
/**
|
|
* Check if player has discovered an anchor
|
|
*/
|
|
hasDiscovered(anchorId: string, playerPubKey: string): boolean {
|
|
const discoveries = this.discoveries.get(anchorId) ?? [];
|
|
return discoveries.some((d) => d.playerPubKey === playerPubKey);
|
|
}
|
|
|
|
/**
|
|
* Get discovery count for anchor
|
|
*/
|
|
getDiscoveryCount(anchorId: string): number {
|
|
return (this.discoveries.get(anchorId) ?? []).length;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Events
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Subscribe to events
|
|
*/
|
|
on(listener: GameEventListener): () => void {
|
|
this.listeners.add(listener);
|
|
return () => this.listeners.delete(listener);
|
|
}
|
|
|
|
private emit(event: GameEvent): void {
|
|
for (const listener of this.listeners) {
|
|
try {
|
|
listener(event);
|
|
} catch (e) {
|
|
console.error('Error in game event listener:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Serialization
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Export all anchors and discoveries
|
|
*/
|
|
export(): string {
|
|
return JSON.stringify({
|
|
anchors: Array.from(this.anchors.entries()),
|
|
discoveries: Array.from(this.discoveries.entries()),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Import anchors and discoveries
|
|
*/
|
|
import(json: string): void {
|
|
const data = JSON.parse(json);
|
|
this.anchors = new Map(data.anchors);
|
|
this.discoveries = new Map(data.discoveries);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Factory Functions
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Create an anchor manager
|
|
*/
|
|
export function createAnchorManager(
|
|
config?: Partial<AnchorManagerConfig>
|
|
): AnchorManager {
|
|
return new AnchorManager(config);
|
|
}
|
|
|
|
/**
|
|
* Create a simple reward
|
|
*/
|
|
export function createReward(params: {
|
|
type: DiscoveryReward['type'];
|
|
rewardId: string;
|
|
quantity?: number;
|
|
rarity?: DiscoveryReward['rarity'];
|
|
firstFinderOnly?: number;
|
|
dropChance?: number;
|
|
}): DiscoveryReward {
|
|
return {
|
|
type: params.type,
|
|
rewardId: params.rewardId,
|
|
quantity: params.quantity ?? 1,
|
|
rarity: params.rarity ?? 'common',
|
|
firstFinderOnly: params.firstFinderOnly,
|
|
dropChance: params.dropChance,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a text hint
|
|
*/
|
|
export function createTextHint(
|
|
text: string,
|
|
revealCondition: HintRevealCondition = { type: 'immediate' }
|
|
): Omit<AnchorHint, 'id'> {
|
|
return {
|
|
revealCondition,
|
|
content: { type: 'text', text },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a hot/cold hint
|
|
*/
|
|
export function createHotColdHint(
|
|
precisionLevel: number
|
|
): Omit<AnchorHint, 'id'> {
|
|
return {
|
|
revealCondition: { type: 'immediate' },
|
|
content: { type: 'hotCold', temperature: 0 }, // Temperature calculated dynamically
|
|
precisionLevel,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a riddle hint
|
|
*/
|
|
export function createRiddleHint(
|
|
riddle: string,
|
|
answer?: string,
|
|
revealCondition: HintRevealCondition = { type: 'immediate' }
|
|
): Omit<AnchorHint, 'id'> {
|
|
return {
|
|
revealCondition,
|
|
content: { type: 'riddle', riddle, answer },
|
|
};
|
|
}
|