#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Lab Survival — Part 2 (expanded)
Changes from previous version:
- The game now STARTS with Amie trapped in her room and she must escape (QTE / choices).
- Multiple, specific endings have been added based on who survives and the bonds formed.
- Characters who die are removed from dialogue and from later scenes (already implemented).
- Added more character interactions that influence final endings.
Run: python Lab_Survival_Part2.py
"""
from __future__ import annotations
import sys, time, random, textwrap
from dataclasses import dataclass, field
from typing import Dict, List, Callable, Optional
WRAP = 88
def say(text: str, wrap: int = WRAP):
print("
" + "
".join(textwrap.fill(line, wrap) for line in text.split("
")))
@dataclass
class Character:
name: str
alive: bool = True
bond: int = 0 # Player bond; range approx -10..+10
traits: Dict[str, int] = field(default_factory=dict)
def speak(self, line: str):
if self.alive:
say(f"{self.name}: {line}")
def adjust_bond(self, delta: int):
self.bond += delta
@dataclass
class GameState:
cast: Dict[str, Character] = field(default_factory=dict)
inventory: Dict[str, int] = field(default_factory=dict)
flags: Dict[str, bool] = field(default_factory=dict)
scene: str = "room_escape"
rng: random.Random = field(default_factory=lambda: random.Random())
def living(self) -> List[Character]:
return [c for c in self.cast.values() if c.alive]
def get(self, who: str) -> Character:
return self.cast[who]
def kill(self, who: str, reason: str = ""):
c = self.cast.get(who)
if c and c.alive:
c.alive = False
say(f"*** {c.name} dies. {reason}".strip())
def bond_summary(self):
rows = [f" - {c.name}: {c.bond:+d}" for c in self.living()]
say("Bond Meter (living):
" + "
".join(rows))
# ---------------- QTE helpers ---------------- #
def qte_sequence(prompt: str, length: int = 3, time_limit: float = 4.0, alphabet: str = "ASDWJIKL") -> bool:
target = "".join(random.choice(alphabet) for _ in range(length))
say(f"QTE — {prompt}
Type this sequence fast (no spaces): [{target}]
Time limit: {time_limit:.1f}s")
t0 = time.time()
user = input(">>> ").strip().upper()
elapsed = time.time() - t0
ok = (user == target and elapsed <= time_limit)
if ok:
say(f"✔ Success! ({elapsed:.2f}s)")
else:
say(f"✖ Failed. You typed '{user}' in {elapsed:.2f}s; target was '{target}'.")
return ok
# --------------- Scenes ----------------------- #
def scene_room_escape(gs: GameState):
say("
— PART 2: CHAPTER — ESCAPE —
")
say("You wake bound to a hospital-style bed. Flickering fluorescent hums. The door to your dorm room is welded from the outside. A dim CCTV monitor shows a shadow moving past the hall outside.")
gs.get("Amie").speak("...Who did this? My camera's gone. My phone's dead.")
gs.get("Alex").speak("Amie? If you can hear me, make noise. We're coming.")
gs.get("Jake").speak("Find the bed release. I'll jam the lock from the hall if you can get out the window.")
say("Options:")
say(" 1) Fiddle the bed-release with the metal clip (QTE — dexterity).
2) Kick the window and try to shimmy through the sill (QTE — endurance).
3) Shout loudly to lure help (risk: attracts monsters).")
choice = input("> ").strip()
if choice == "1":
ok = qte_sequence("twist the clip under the pin", length=4, time_limit=4.0)
if ok:
say("The pin snaps. You free your wrist and crawl to the door. Alex and Jake are there within moments, prying at welds.")
gs.get("Alex").adjust_bond(+2)
gs.get("Jake").adjust_bond(+1)
gs.scene = "corridor_intro"
else:
say("The clip shatters and the noise wakes something distant. Footsteps thunder toward the door.")
# small chance Amie dies or someone else breaks in
if gs.rng.random() < 0.25:
gs.kill("Amie", "A shadow smothers the light. Something bites the throat.")
gs.scene = "post_capture"
else:
say("A sliver in the door allows Alex to shove a screwdriver through - they pry you free but you're shaken.")
gs.get("Alex").adjust_bond(+1)
gs.scene = "corridor_intro"
elif choice == "2":
ok = qte_sequence("brace the kick and push your hips through", length=4, time_limit=4.2)
if ok:
say("The sash cracks. You tumble into the night, scraping glass, and meet up with Alex and Jake outside the building.")
gs.get("Amie").adjust_bond(+1)
gs.get("Jake").adjust_bond(+1)
gs.scene = "corridor_intro"
else:
say("You slip and cut your leg badly. The pain is sharp; you crawl back under the bed.")
gs.get("Amie").traits["leg_wounded"] = 1
gs.scene = "corridor_intro"
else:
say("You scream at the top of your lungs. Lights flicker. The hallway outside answers with faraway metallic taps.")
gs.flags["loud_start"] = True
# Increase monster attention later
gs.scene = "corridor_intro"
def scene_corridor_intro(gs: GameState):
say("You and the group gather in a ruined corridor. Pipes drip. Three sets of wet footprints lead toward the research wing.")
# Update dialogue only with alive characters
for name in ("Amie","Alex","Jake","Maria","Sarah","Leo","Chloe","Ben"):
if gs.get(name).alive:
gs.get(name).speak(random.choice([
"We need a plan.",
"Don't split up.",
"This place stinks like metal and old blood.",
]))
say("Pick a plan:")
say(" 1) Head for the maintenance shaft (quieter).
2) Smash through the security doors to the lab (noisy but direct).")
c = input("> ").strip()
if c == "1":
gs.get("Maria").adjust_bond(+1)
gs.scene = "maintenance_shaft"
else:
gs.get("Alex").adjust_bond(+1)
gs.flags["loud"] = True
gs.scene = "security_breach"
def scene_maintenance_shaft(gs: GameState):
say("You squeeze into a narrow shaft. Echoes of breathing are above. Suddenly, a loose grate drops and cuts Leo's calf.")
if "leg_wounded" in gs.get("Amie").traits:
say("Amie's wounded leg slows the group.")
gs.get("Leo").adjust_bond(+1)
# small QTE to climb fast
ok = qte_sequence("climb the shaft rungs without slipping", length=4, time_limit=4.0)
if ok:
say("You pop out near the experimental wing. A hulking shadow patrols the atrium below.")
gs.scene = "experimental_atrium"
else:
say("Your foot slips. A friend grabs your arm — but a claw grazes them." )
# lowest bond living loses life sometimes
pool = [c for c in gs.living() if c.name != "Amie"]
if pool and gs.rng.random() < 0.35:
victim = min(pool, key=lambda c: c.bond)
gs.kill(victim.name, "Cut down in the shaft by a metallic claw.")
gs.scene = "experimental_atrium"
def scene_security_breach(gs: GameState):
say("You batter the security door. Sirens howl. The lab's emergency protocols begin to spin up. A patrol heads your way.")
ok = qte_sequence("batter the security lock free", length=3, time_limit=3.5)
if ok:
say("You burst inside. A prototype spear gun sits on a bench — loaded with two rounds.")
gs.inventory["spears"] = 2
gs.scene = "experimental_atrium"
else:
say("You fail to get the door before patrols arrive. In the scuffle, someone is slashed.")
pool = [c for c in gs.living() if c.name != "Amie"]
if pool:
victim = min(pool, key=lambda c: c.bond)
gs.kill(victim.name, "Cut down while the door groaned.")
gs.scene = "experimental_atrium"
def scene_experimental_atrium(gs: GameState):
say("The atrium is a glass forest of tanks. The three-headed creature hunts here, and cultist figures move in rehearsed patterns.")
# Branch to the earlier gene_wing style sequence but simplified
say("You can: 1) Attempt to trap the creature in a melting pit (risky QTE). 2) Try to lure cultists to distract it and slip past.")
ch = input("> ").strip()
if ch == "1":
ok = qte_sequence("lure it over the pit and trigger the gate", length=5, time_limit=4.0)
if ok:
say("The pit opens; molten metal spits. Two heads are burned but one claws free and flees. You follow through the service tunnel.")
gs.flags["heads_burned"] = True
gs.scene = "service_tunnel"
else:
say("You mistime it; the creature turns and eats a friend in one brutal motion.")
pool = [c for c in gs.living() if c.name != "Amie"]
if pool:
victim = min(pool, key=lambda c: c.bond)
gs.kill(victim.name, "Torn apart when the pit opened late.")
gs.scene = "service_tunnel"
else:
say("You bait the cultists; a firefight erupts. In the confusion you slip to a maintenance ladder.")
# small bond effects
for who in ("Maria","Sarah"):
if gs.get(who).alive:
gs.get(who).adjust_bond(+1)
gs.scene = "service_tunnel"
def scene_service_tunnel(gs: GameState):
say("The service tunnel leads to a heavy blast door and a river exit. The creature is wounded but enraged.")
say("Final choices:
1) Seal the creature in the lab and detonate charges (suicide risk).
2) Try to flee through the river (escape).
3) Ambush with remaining weapons (last stand).")
c = input("> ").strip()
if c == "1":
# detonating: QTE to arm or fail; can kill many
ok = qte_sequence("wire the charges quickly", length=5, time_limit=4.2)
if ok:
say("You arm the charges and lock the blast door. Everyone runs. The lab explodes, but the blast kills or injures several.")
# survivors depend on random and bonds
resolve_final_casualties(gs, explosive=True)
else:
say("The charges misfire; the blast isn't contained. The lab begins to collapse.")
resolve_final_casualties(gs, explosive=False)
elif c == "2":
say("You rush for the river and the night. It's a long run. A few don't make it.")
resolve_final_casualties(gs, river_escape=True)
else:
say("You set an ambush using the spear gun and whatever you can find.")
ambush_success = False
if gs.inventory.get("spears", 0) > 0:
ambush_success = qte_sequence("aim and fire the spear gun", length=3, time_limit=3.0)
if ambush_success:
say("The creature falls. You're battered but alive. Small groups drift apart in the aftermath.")
resolve_final_casualties(gs, ambush=True)
else:
say("The ambush fails. Several are taken. You barely escape.")
resolve_final_casualties(gs, ambush=False)
def resolve_final_casualties(gs: GameState, explosive: bool = False, river_escape: bool = False, ambush: bool = False):
living = gs.living()
# Use bonds + randomness to decide who survives. Lower bond = higher chance to die.
# We'll calculate death probability per person.
for c in list(living):
base_dead_chance = 0.12
if explosive:
base_dead_chance += 0.25
if river_escape:
base_dead_chance += 0.10
if ambush and not explosive:
base_dead_chance += 0.05
# modify by bond (more negative bond increases chance)
bond_mod = -0.02 * c.bond
final = base_dead_chance + bond_mod
roll = gs.rng.random()
if roll < final:
gs.kill(c.name, "Caught in the final chaos.")
# ensure Amie has a chance to die in some endings
if not gs.get("Amie").alive:
# direct to epilogue
the_epilogue(gs)
return
# If Amie lives, determine the ending relationships
the_epilogue(gs)
# --------------- Endings --------------------- #
def the_epilogue(gs: GameState):
say("
— EPILOGUE & ENDINGS —")
survivors = [c.name for c in gs.living()]
dead = [c.name for c in gs.cast.values() if not c.alive]
say("Survivors: " + (", ".join(survivors) if survivors else "(none)"))
if dead:
say("Fallen: " + ", ".join(dead))
# Compute pairings / endings per user's specification using rules
# Priority checks for pairings
def alive(n):
return gs.cast[n].alive
# helper: top bond among survivors besides Amie
others = [c for c in gs.living() if c.name != "Amie"]
top_other = max(others, key=lambda x: x.bond, default=None)
ending_text = "You walk away..."
# If Amie dead: pick an ending for others or solo
if not alive("Amie"):
# Several specific endings when Amie dies
if alive("Alex") and alive("Jake") and not any(alive(n) for n in ("Maria","Leo","Sarah")):
ending_text = "Alex x Jake ending: Alex and Jake, broken and guilty, find one another and cling together — a quiet pact in a ruined world."
elif alive("Leo") and not any(alive(n) for n in ("Alex","Jake","Maria","Sarah")):
ending_text = "Leo solo: haunted, he cannot bear the guilt and takes his life by the river."
else:
ending_text = "No one rejoices. Survivors carry the weight. Some walk off in silence."
say(ending_text)
gs.bond_summary()
sys.exit(0)
# Amie is alive -> many possibilities
# Check for pair endings specified by the user
# Highest priority: explicit pair combos if both alive
pairs = [
("Amie","Alex","Amie x Alex ending, they want to get married"),
("Amie","Jake","Amie x Jake ending, they get together"),
("Amie","Maria","Amie x Maria ending, they get together"),
("Amie","Leo","Amie x Leo ending, Amie ends Leo after he is injured"),
("Amie","Sarah","Amie x Sarah ending, Sarah dies from asthma after destroying the lab, Amie cries"),
]
for a,b,txt in pairs:
if alive(a) and alive(b):
# special check for Leo ending: Leo must be injured for Amie to kill him
if b == "Leo" and gs.get("Leo").traits.get("injured_calculus") is None:
# if not injured, continue checking others
pass
else:
say(txt)
gs.bond_summary()
sys.exit(0)
# Other two-person endings involving Alex/Jake etc.
if alive("Alex") and alive("Jake") and not alive("Amie"):
say("Alex x Jake ending, they get together (but Amie isn't there).")
gs.bond_summary()
sys.exit(0)
# If Amie is with someone with highest bond -> pair
if top_other:
# use bond threshold to decide romance
if top_other.bond >= 2:
if top_other.name == "Alex":
say("Amie x Alex ending: they want to get married — a bright future after the nightmare.")
gs.bond_summary(); sys.exit(0)
if top_other.name == "Jake":
say("Amie x Jake ending: they get together, unexpected and raw.")
gs.bond_summary(); sys.exit(0)
if top_other.name == "Maria":
say("Amie x Maria ending: they get together and try to rebuild.")
gs.bond_summary(); sys.exit(0)
if top_other.name == "Leo":
# If Leo injured and low bond, Amie ends him
if gs.get("Leo").traits.get("injured_calculus") and gs.get("Leo").bond < 0:
say("Amie x Leo ending: after Leo's injury, Amie smiles before she shoots him with the spear gun.")
gs.bond_summary(); sys.exit(0)
else:
say("Amie x Leo ending: they get together (soft, brief).")
gs.bond_summary(); sys.exit(0)
if top_other.name == "Sarah":
# Sarah might die from asthma
if gs.get("Sarah").traits.get("severe_asthma"):
say("Amie x Sarah ending: Sarah dies from asthma after destroying the lab; Amie cries over her.")
else:
say("Amie x Sarah ending: they get together and promise each other safety.")
gs.bond_summary(); sys.exit(0)
# Solo or other group endings
# Specific combinations from user's list
if alive("Alex") and not any(alive(n) for n in ("Jake","Maria","Leo","Sarah")):
say("Alex solo: Alex smirks and just walks off — he did what had to be done.")
gs.bond_summary(); sys.exit(0)
if alive("Jake") and not any(alive(n) for n in ("Alex","Maria","Leo","Sarah")):
say("Jake solo: Jake walks off crying, swearing to kill all of these unknown creatures.")
gs.bond_summary(); sys.exit(0)
if alive("Maria") and not any(alive(n) for n in ("Alex","Jake","Leo","Sarah")):
say("Maria solo: she walks alone until a prototype metallic beast finds her.")
gs.bond_summary(); sys.exit(0)
if alive("Sarah") and not any(alive(n) for n in ("Alex","Jake","Maria","Leo")):
say("Sarah solo: she dies from asthma, alone and battered.")
gs.bond_summary(); sys.exit(0)
if alive("Leo") and not any(alive(n) for n in ("Alex","Jake","Maria","Sarah")):
say("Leo solo: wracked by guilt, he ends his life by the river.")
gs.bond_summary(); sys.exit(0)
# Trio endings (couple plus a third or three together)
if alive("Amie") and alive("Alex") and alive("Jake"):
say("Trio ending: Amie, Alex and Jake walk away together, friends more than lovers — their bonds complicated but intact.")
gs.bond_summary(); sys.exit(0)
if alive("Amie") and alive("Maria") and alive("Jake"):
say("Trio ending: the unlikely trio escapes together and picks a quiet place to heal.")
gs.bond_summary(); sys.exit(0)
# Default: Amie solo
say("Amie solo: Amie walks off, not knowing where she is, just crying.")
gs.bond_summary()
sys.exit(0)
# -------------- Bootstrapping ----------------- #
def build_game(seed: Optional[int] = None) -> GameState:
rng = random.Random(seed)
cast = {
"Amie": Character("Amie", True, 0, {"role":"protagonist"}),
"Alex": Character("Alex", True, 0, {"brash":1}),
"Sarah": Character("Sarah", True, 0, {"anxious":1, "severe_asthma":0}),
"Jake": Character("Jake", True, 0, {"pragmatic":1}),
"Maria": Character("Maria", True, 0, {"clever":1}),
"Chloe": Character("Chloe", True, 0, {"vain":1}),
"Leo": Character("Leo", True, 0, {"loyal":1}),
"Ben": Character("Ben", True, 0, {"earnest":1}),
}
return GameState(cast=cast, inventory={}, flags={}, scene="room_escape", rng=rng)
SCENES: Dict[str, Callable[[GameState], None]] = {
"room_escape": scene_room_escape,
"corridor_intro": scene_corridor_intro,
"maintenance_shaft": scene_maintenance_shaft,
"security_breach": scene_security_breach,
"experimental_atrium": scene_experimental_atrium,
"service_tunnel": scene_service_tunnel,
}
def main():
seed = None
if len(sys.argv) > 1 and sys.argv[1].isdigit():
seed = int(sys.argv[1])
gs = build_game(seed)
say("(Tip: pass a number arg for deterministic run. This expanded version begins with Amie trapped in her room.)")
while True:
fn = SCENES.get(gs.scene)
if not fn:
say(f"Unknown scene '{gs.scene}'. Exiting.")
break
fn(gs)
if __name__ == "__main__":
main()