Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
5.4 kB · 126 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127// Balancing. Everything that paces a game is derived by `balance()` from three inputs:// player count, round duration, and resource count. The numbers/curves here are MADE UP for now// (PLAN.md §2, §7.10–11) — they live in one place so tuning is a single-file change.
import type { ActivityKind, GameConfig, ResourceId } from './types.js'
/** Resource names assigned in order; falls back to `res-N` past the palette. */const RESOURCE_PALETTE = ['wood', 'stone', 'wheat', 'ore', 'clay', 'fish', 'gold', 'silk']
export function resourceIds(count: number): ResourceId[] { const out: ResourceId[] = [] for (let i = 0; i < count; i++) out.push(RESOURCE_PALETTE[i] ?? `res-${i + 1}`) return out}
export interface BalanceInput { playerCount: number roundDurationMs: number resourceCount: number /** explicit in-play resource keys (the scenario∩map set); overrides resourceCount/palette when * non-empty, so arbitrary keys like 'beef'/'dirtyDishes' get targets. */ resources?: ResourceId[]}
export interface Balance { config: GameConfig /** concurrent users per resource station */ slotsPerStation: number /** required deposits per resource for a worker win */ resourceTarget: number}
/** * Derive a full game configuration from the three high-level inputs. All formulas are placeholders * tuned by feel, not playtesting. */export function balance(input: BalanceInput): Balance { const players = Math.max(1, Math.floor(input.playerCount)) const roundDurationMs = Math.max(1, Math.floor(input.roundDurationMs)) // explicit in-play keys (from the map/scenario) win; else derive from the count via the palette const resources = input.resources?.length ? [...new Set(input.resources)] : resourceIds(Math.max(1, Math.floor(input.resourceCount))) const resourceCount = resources.length
const timer1Ms = 2_000 // the 1-cost actions (collect/deposit/break/scoreboard/callVote/…) const timer2Ms = 5_000 // repair / fakeRepair const timer3Ms = 8_000 // inspect / sabotage // busier games cool a touch slower, so a slot isn't instantly reusable const slotCooldownMs = timer1Ms + players * 250
// fewer slots than players, so the group must split across stations (divide & conquer) const slotsPerStation = Math.max(1, Math.ceil(players / (resourceCount + 1)))
// rough worker throughput over the round, discounted, then split per resource. // BALANCE TARGET (see PLAN.md §1.7 "Worked example"): reachable by a full honest worker team // despite a couple of votes + some repairs, but NOT by ~N-2 effective workers (one framed-and- // voted-off worker + a permanently non-contributing saboteur + early sabotage damage). The 0.4 // discount is the knob to tune toward that knife-edge in playtesting. const workers = Math.max(1, players - 1) const cycleMs = timer1Ms * 2 + slotCooldownMs + 4_000 // collect + deposit + cooldown + travel const totalDeposits = workers * (roundDurationMs / cycleMs) const resourceTarget = Math.max(1, Math.round((totalDeposits * 0.4) / resourceCount))
const targets: Record<ResourceId, number> = {} for (const r of resources) targets[r] = resourceTarget
const config: GameConfig = { resources, targets, roundDurationMs, timer1Ms, timer2Ms, timer3Ms, slotCooldownMs, debrisTtlMs: 15_000, voteDurationMs: Math.round(20_000 + players * 4_000), // more players, longer to agree moveSpeedPxPerSec: 140, saboteurGraceMs: 45_000, // §3.6: saboteur gone this long mid-round → the round aborts } return { config, slotsPerStation, resourceTarget }}
/** * Chance (0..1) that a collection on a sabotaged station fails (and breaks it), as a function of * BOTH how many times it's been used since the sabotage and how long ago it was sabotaged. Rolled * by the server, with the boolean result baked into the resolving action. Made-up curve: ~+0.2 per * use and ~+0.3 per minute. A negative `usages` (the grace window left by a fakeRepair) reads as 0. */export function sabotageFailureChance(usages: number, msSinceSabotage: number): number { const byUse = Math.max(0, usages) * 0.2 const byTime = (Math.max(0, msSinceSabotage) / 60_000) * 0.3 return Math.min(1, byUse + byTime)}
/** * Whether an activity is a Collect or its disguise (Pretend Collect). Both REQUIRE a regenerated * slot — you can't fake a collect where a real one is impossible (that would be a crude tell). But * only a real Collect *consumes* the cooldown; a fake one deliberately doesn't, which makes it low * value (no slot denial) and leaves a subtle tell for an observer tracking the slot. Use this for * the "needs a collectable slot" gate; gate the cooldown *burn* on `kind === 'collect'` directly. */export function isCollectLike(kind: ActivityKind): boolean { return kind === 'collect' || kind === 'pretendCollect'}
/** Timer (ms) an activity consumes, by tier. */export function timerFor(kind: ActivityKind, config: GameConfig): number { switch (kind) { case 'collect': case 'pretendCollect': case 'breakStation': case 'deposit': case 'fakeDeposit': case 'scoreboard': case 'pretendScoreboard': case 'callVote': return config.timer1Ms case 'repair': case 'fakeRepair': return config.timer2Ms case 'inspect': case 'sabotage': return config.timer3Ms }}