Add storage-backed story history

This commit is contained in:
2026-05-15 21:58:30 +02:00
parent f2e786d5bc
commit 42582352d6
16 changed files with 1048 additions and 113 deletions
+513 -61
View File
@@ -1,7 +1,8 @@
// Kaiserpunk Horror Intro
// Ink source file draft
// Covers: opening train journey, class selection, name builder, supernatural stance,
// spiritual-sense selection, first Viktor relationship choices, and arrival at Eibenreith.
// Covers: opening train journey, class selection, name builder, baggage definition,
// mirror/self-description, supernatural stance, spiritual-sense selection,
// first Viktor relationship choices, and arrival at Eibenreith.
VAR birth_class = "unset"
VAR title_part = ""
@@ -10,6 +11,12 @@ VAR common_name = ""
VAR surname = ""
VAR full_name = ""
VAR baggage_style = "unset"
VAR hair_detail = "unset"
VAR complexion_detail = "unset"
VAR face_detail = "unset"
VAR outfit_detail = "unset"
VAR supernatural_belief = "unset" // believer, sceptic, performer, undecided
VAR supernatural_senses = "unset" // genuine, faked, repressed, ambiguous
VAR viktor_relation = "unset" // trust, tension, utility, dependence, provocation
@@ -52,12 +59,10 @@ In truth, he is an officer lent to a delicate matter by channels that prefer not
He folds the newspaper, though you are quite certain he had not been reading it.
"You have been very quiet, gnädiges Fräulein."
"You have been very quiet, gnädiges Fräulein. For a lady on her first official journey, you show remarkable restraint."
The form of address is technically correct if you are noble, excessive if you are not, and perfectly chosen because he does not yet know which part of you is useful, which part is costume, and which part is threat.
"For a lady on her first official journey," he adds, "you show remarkable restraint."
You look around the compartment before you answer. The answer comes from somewhere older than the letter in your reticule. It comes from the place you began.
* [The compartment seems built for people who never wonder whether they belong in it.] #class:noble
@@ -90,9 +95,79 @@ You look around the compartment before you answer. The answer comes from somewhe
=== class_noble_background ===
"Restraint is not a virtue, Herr Nowak," you say. "It is often only good breeding with its mouth shut."
Viktor waits for the answer owed to his remark. The train jolts once, then settles again into its hard, confident rhythm.
His brows move almost imperceptibly.
* "Restraint is not a virtue, Herr Nowak. It is often only good breeding with its mouth shut."
~ eccentric += 1
His brows move almost imperceptibly.
"Then good breeding has military applications," he says.
* "Only when properly commanded."
~ lover += 1
~ viktor_suspicion += 1
"You intend to command it yourself?"
"Whenever possible."
* "Only when men mistake silence for obedience."
~ sapphic += 1
~ viktor_suspicion += 1
"That is an ambitious distinction."
"It has preserved many women from being understood too early."
* "I prefer any discipline that leaves a clean record."
~ detective += 1
~ viktor_trust += 1
"A useful preference," he says. "If sincere."
"If it is not sincere, I have stated it early enough for you to investigate."
-
-> class_noble_explanation
* "You need not test whether I can sit still, Herr Nowak. I was trained by people with less patience and sharper eyes."
~ detective += 1
~ viktor_trust += 1
"A family education, then."
* "A family sentence, more often."
~ eccentric += 1
"You speak as if birth were a prison."
"Only when it is furnished well enough for visitors to admire."
* "An education in rooms where every chair has rank."
~ class_confidence += 1
"Then Hohenreith may not surprise you."
"That depends on whether they have better chairs than secrets."
-
-> class_noble_explanation
* "If this is remarkable restraint, Herr Nowak, I fear you have mostly escorted officers."
~ lover += 1
~ viktor_suspicion += 1
The corner of his mouth changes almost too little to notice.
"Officers are less easily bored."
* "Or less honest about it."
~ lover += 1
"You accuse the army of vanity."
"No. I accuse men of consistency."
* "Then I must try not to disappoint the army."
~ careless += 1
"That is precisely what I have been asked to prevent."
-
-> class_noble_explanation
=== class_noble_explanation ===
You learned young that every room contains a court, even when no emperor is present. A girl of your rank is trained to enter, to bow, to be introduced, to be placed, to speak only enough, to understand more than she admits, and to know that a family name can be both a key and a chain.
@@ -108,9 +183,73 @@ Now choose the name by which Vienna invented you.
=== class_middle_background ===
"Restraint," you say, "is easier when one has learned that every mistake is remembered by someone better placed."
Viktor's remark lingers between you with the odour of a polite accusation.
Viktor watches you more closely.
* "Restraint is easier when one has learned that every mistake is remembered by someone better placed."
~ detective += 1
~ viktor_trust += 1
Viktor watches you more closely.
"A bitter lesson."
* "A useful one. Bitterness is merely the taste left by instruction."
~ eccentric += 1
"You collect phrases like weapons."
"Only the light ones. Heavy weapons attract permits."
* "A common one. Some people only notice injustice when it reaches their own floor."
~ sapphic += 1
"You have made a study of floors?"
"Of thresholds. They are more honest."
-
-> class_middle_explanation
* "If I am quiet, Herr Nowak, it is because men explain themselves faster when they dislike the silence."
~ lover += 1
~ medium_reputation += 1
"A method?"
* "A courtesy. I let them begin with their favorite subject."
~ lover += 1
"Themselves."
"You understand the principle already."
* "An experiment. It has produced reliable results."
~ detective += 1
"Then I am part of your experiment."
"Naturally. You sat opposite me."
-
-> class_middle_explanation
* "I was considering whether your concern is official, personal, or merely masculine."
~ eccentric += 1
~ viktor_suspicion += 1
His eyes harden by one exact degree.
"Today it is official."
* "How convenient. The other two may deny responsibility."
~ eccentric += 1
"I advise you not to make wit your first instrument at Hohenreith."
"Then I shall make it the second."
* "Then I shall treat it with the respect due to paper."
~ detective += 1
"Paper has moved armies."
"And buried mistakes."
-
-> class_middle_explanation
=== class_middle_explanation ===
You were born in that broad, anxious territory between deference and ambition. Your family had books, invoices, respectability, perhaps a piano no one played well enough, perhaps a father with an office, a mother with callers, brothers who were expected to advance, and daughters who were expected not to make advancement look hungry.
@@ -126,9 +265,65 @@ Now choose the name under which you entered the salons that first laughed at you
=== class_working_background ===
"Restraint," you say, "is what people praise when they prefer not to see the effort."
Viktor's courtesy is smooth enough to be handled without fingerprints. You hear, beneath it, the question of how much this compartment has improved you.
The newspaper in Viktor's hand creases once.
* "Restraint is what people praise when they prefer not to see the effort."
~ detective += 1
~ viktor_trust += 1
The newspaper in Viktor's hand creases once.
"You object to being praised?"
* "Only cheaply."
~ eccentric += 1
"That may be difficult to avoid."
"Then Hohenreith will have to spend more."
* "Only when it hides the person doing the work."
~ sapphic += 1
He studies you as if the answer has come from farther down the train than first class.
-
-> class_working_explanation
* "I am quiet because people often prefer women of my origin either grateful or invisible. I am deciding which will inconvenience you less."
~ eccentric += 1
~ viktor_suspicion += 1
"I did not ask you to be grateful."
* "No. You asked me to be manageable."
~ eccentric += 1
"I asked you nothing of the kind."
"That is the benefit of rank. It asks through furniture."
* "Then I shall postpone gratitude until you deserve it."
~ lover += 1
A pause. Then, very dryly: "A generous arrangement."
-
-> class_working_explanation
* "I was trying not to touch the upholstery as though it might accuse me."
~ careless += 1
~ viktor_relation = "dependence"
Something like concern crosses his face, disguised too late as irritation.
"The upholstery has survived ministers. It will survive you."
* "Then I am already braver than I was."
~ careless += 1
"Courage measured against upholstery is not a military standard."
* "I shall take that as reassurance, though you delivered it like a reprimand."
~ lover += 1
"I have had practice with both."
-
-> class_working_explanation
=== class_working_explanation ===
You were born among people who owned little but obligations. Work had a sound before it had a meaning: water, broom, bootsteps, breath, the clatter of dishes, the cough of men coming in from cold yards, women counting coins under their breath. You learned early that the high-born are not more observant than others. They are merely less often required to observe.
@@ -377,7 +572,105 @@ Viktor has waited through your silence with a soldier's patience and a jailer's
When the mountains return, they seem closer.
-> supernatural_stance
-> mirror_definition
=== mirror_definition ===
The black glass gives you the woman who will arrive in Eibenreith before any rumour has time to improve her.
Choose what the window catches first.
* [Dark ash-brown hair pinned with almost severe care.]
~ hair_detail = "dark ash-brown"
The reflection has dark ash-brown hair, almost black where the tunnel has not quite left it, pinned with the severity of a woman who knows that disarray is forgiven less easily in the young.
-> mirror_complexion
* [Chestnut hair arranged to look softer than the mind beneath it.]
~ hair_detail = "chestnut"
The reflection has chestnut hair, warm where the lamp touches it, arranged with enough softness to flatter and enough control to warn the attentive.
-> mirror_complexion
* [Cool fair hair made austere by dark travelling clothes.]
~ hair_detail = "cool fair"
The reflection has cool fair hair, not golden enough for sentimental painters, but pale enough that the dark travelling clothes make your face appear more deliberate than gentle.
-> mirror_complexion
* [Black-brown hair with a few escaped wisps already refusing discipline.]
~ hair_detail = "black-brown"
The reflection has black-brown hair, glossy in the lamp's weak tremor, with two escaped wisps at the temple already committing small treasons against the pins.
-> mirror_complexion
=== mirror_complexion ===
The window deepens, and with it the face.
* [A fair, cool complexion that looks almost bloodless in railway light.]
~ complexion_detail = "fair and cool"
Your complexion is fair and cool, made paler by smoke, glass, and the faint greenish cast of mountain light.
-> mirror_face
* [A clear complexion that still remembers the city more than the sun.]
~ complexion_detail = "clear and sheltered"
Your complexion has the clarity of rooms, gloves, and shaded streets; not sickly, but visibly protected from the labour that browns other lives.
-> mirror_face
* [A face that has learned to look fragile when fragility is useful.]
~ complexion_detail = "delicately pale"
Your complexion is delicately pale, the sort physicians and foolish men read too eagerly, and which you have never felt obliged to correct every time.
-> mirror_face
* [A slightly warmer complexion that makes the severity of the outfit less forgiving.]
~ complexion_detail = "warm fair"
Your complexion is warmer than Vienna's winter light would prefer, and that warmth makes the severe coat and collar seem chosen rather than imposed.
-> mirror_face
=== mirror_face ===
The window keeps you just long enough to make judgement impolite.
* [A long oval face, observant before it is beautiful.]
~ face_detail = "long oval and observant"
The face is long and oval, with watchful eyes, a straight nose, and a mouth that knows how often women are punished for amusement before men call them clever.
-> mirror_outfit
* [A composed face that seems trained equally for salons and interrogations.]
~ face_detail = "composed and trained"
The face is composed rather than soft, with brows dark enough to sharpen silence and a mouth whose politeness has not yet promised mercy.
-> mirror_outfit
* [A pretty face made less harmless by the steadiness of the eyes.]
~ face_detail = "pretty but steady"
The face might be called pretty by people who do not like to work harder for adjectives, but the eyes disturb the compliment by appearing to have heard it before and found it insufficient.
-> mirror_outfit
* [A severe face that becomes almost vulnerable only when caught unprepared.]
~ face_detail = "severe and guarded"
The face is severe at first glance, guarded at the second, and only after that does it betray how young a woman may still be while carrying herself like a sealed document.
-> mirror_outfit
=== mirror_outfit ===
The rest of the reflection is costume, armour, and evidence.
* [Dark charcoal-plum travelling wool, restrained but expensive.]
~ outfit_detail = "charcoal-plum travelling wool"
You wear a tailored travelling ensemble of dark charcoal wool with a plum undertone, high at the throat, close at the waist, expensive in the way that avoids asking to be admired.
-> supernatural_stance
* [A severe black-brown coat and skirt, softened only by ivory at throat and cuffs.]
~ outfit_detail = "black-brown travelling suit"
You wear a black-brown travelling suit and long coat, softened only by ivory at throat and cuffs, the lace narrow enough to seem like restraint rather than decoration.
-> supernatural_stance
* [Bottle-green details hidden in an otherwise sombre travelling suit.]
~ outfit_detail = "sombre suit with bottle-green details"
You wear a sombre travelling suit with bottle-green details so discreet that only close attention discovers them, which is nearly the point.
-> supernatural_stance
* [A more fashionable ensemble, dark and narrow, calculated to be remembered.]
~ outfit_detail = "fashionable dark travelling ensemble"
You wear a dark, narrow, more fashionable travelling ensemble, the hat and veil chosen with just enough theatrical instinct to make sceptical people use the word instinctive.
-> supernatural_stance
=== supernatural_stance ===
@@ -387,7 +680,7 @@ It calls you, in prose dry enough to pass through any number of offices, a woman
The comital family at Jagdhaus Hohenreith has asked for discretion. Vienna has answered with a sealed letter, a woman reputed to speak with what is hidden, and a man opposite her who has orders of his own.
Before this journey, before this train, before the mountains began taking the sky piece by piece, what did you believe?
Before this journey, before this train, before the mountains began taking the sky piece by piece, belief had already taken its position in you.
* [The dead are not silent. The living are merely poor listeners.] #supernatural:believer
~ supernatural_belief = "believer"
@@ -431,7 +724,7 @@ Belief is one matter. Experience is another.
People call a woman sensitive when they want her perceptions to sound like an illness. They call her hysterical when those perceptions inconvenience them. They call her inspired when they need her, and unstable when they do not.
What, beneath reputation and performance, has truly happened to you?
Beneath reputation and performance, memory has its own testimony.
* [There have been moments you cannot explain away.] #powers:genuine
~ supernatural_senses = "genuine"
@@ -477,19 +770,36 @@ Viktor opens a leather folder and removes a memorandum. He does not hand it to y
"When we leave the railway," he says, "we will be met by a coach from Hohenreith. From that moment, appearances matter. Your hosts have been told that I assist with correspondence, travel, and practical arrangements. They need not be troubled with military definitions."
"And the villagers?" you ask.
* "And the villagers?"
"The villagers need not be troubled with anything."
"The villagers need not be troubled with anything."
There it is: the empire in miniature. A man, a folder, a locked sentence.
There it is: the empire in miniature. A man, a folder, a locked sentence.
* "How merciful. The empire has spared them vocabulary."
~ eccentric += 1
~ viktor_suspicion += 1
"The empire has spared them alarm," Viktor says.
"It often uses the two interchangeably."
There it is: the empire in miniature. A man, a folder, a locked sentence.
* "You mean they are not to know whether I am guest, tool, or warning."
~ detective += 1
~ viktor_trust += 1
"I mean they are to know only what steadies the situation."
"That is not a contradiction."
There it is: the empire in miniature. A man, a folder, a locked sentence.
-
"You will be addressed according to the station you present," he continues. "The Graf's household will observe rank. Servants will observe what the household observes. Villagers may observe less and remember more. I advise restraint."
The advice is sound. That makes it no less irritating.
How do you answer him?
* ["If gentlemen were less easily led, Herr Nowak, ladies would require fewer methods."] #route:lover
* "If gentlemen were less easily led, Herr Nowak, ladies would require fewer methods." #route:lover
~ lover += 1
~ viktor_relation = "provocation"
~ viktor_trust -= 1
@@ -498,17 +808,29 @@ How do you answer him?
"A dangerous doctrine."
"A practical one."
* "A practical one."
"You intend to practice it at Hohenreith?"
"Only where patriotism requires sacrifice."
* "Only where patriotism requires sacrifice."
~ lover += 1
He looks down at the memorandum, but not quickly enough to conceal that he is reassessing you.
* "Only where men mistake desire for judgement."
~ lover += 1
"That may include more territory than the maps admit."
-
-> viktor_explains_orders
* "Dangerous doctrines travel best in good gloves."
~ eccentric += 1
"You intend to charm Hohenreith into confession?"
"Only if Hohenreith insists on being charmed."
-> viktor_explains_orders
* ["If you wish me to pass as harmless, you must stop warning me like a gaoler."] #route:sapphic
* "If you wish me to pass as harmless, you must stop warning me like a gaoler." #route:sapphic
~ sapphic += 1
~ viktor_relation = "tension"
~ viktor_suspicion += 1
@@ -516,15 +838,21 @@ How do you answer him?
"I am not your gaoler."
"No. A gaoler is at least honest about the key."
* "No. A gaoler is at least honest about the key."
The words surprise you by leaving a mark. Not on him, perhaps. On yourself. The closer the train carries you to Amalia's world, though you do not yet know her face, the more intolerable it seems that every female life there might be guarded by men who call the guarding concern.
* "Then do not stand between me and every locked door before I have touched the handle."
~ viktor_trust += 1
"Some doors are locked for cause."
"Then the cause had better survive examination."
-
Viktor folds the memorandum once, precisely.
-> viktor_explains_orders
* ["Then let us be exact. What do they know, what do they suspect, and what am I permitted to verify?"] #route:detective
* "Then let us be exact. What do they know, what do they suspect, and what am I permitted to verify?" #route:detective
~ detective += 1
~ viktor_relation = "professional"
~ viktor_trust += 1
@@ -532,17 +860,22 @@ How do you answer him?
"They know that you come recommended. They suspect that you may be able to settle disturbances without police, priest, or press. You are permitted to verify fraud, coercion, threat to public order, or credible phenomena not presently classifiable."
"Credible phenomena not presently classifiable."
* "Credible phenomena not presently classifiable."
"That is the phrase."
"A bureaucratic ghost."
"The safest kind."
* "And if the phenomena become classifiable?"
"Then we classify them before others do."
"Spoken like a man who has seen reports bury graves."
-
-> viktor_explains_orders
* ["I shall do my best not to faint unless it is useful."] #route:careless
* "I shall do my best not to faint unless it is useful." #route:careless
~ careless += 1
~ viktor_relation = "dependence"
~ viktor_trust -= 1
@@ -550,8 +883,7 @@ How do you answer him?
"I would prefer you did not faint at all."
"How ungallant."
* "How ungallant."
"How practical."
"Then you must be practical for both of us. I have never trusted the floor in strange houses."
@@ -560,9 +892,16 @@ How do you answer him?
"That, gnädiges Fräulein, is precisely what concerns me."
* "Then you must remain close enough to catch me."
~ lover += 1
"My orders did not specify theatrical collapses."
"How careless of them."
-
-> viktor_explains_orders
* ["Restraint is what timid people call obedience after they have forgotten who trained them."] #route:eccentric
* "Restraint is what timid people call obedience after they have forgotten who trained them." #route:eccentric
~ eccentric += 1
~ viktor_relation = "challenge"
~ viktor_suspicion += 2
@@ -570,12 +909,17 @@ How do you answer him?
"You enjoy making enemies."
"No. I dislike the laziness of letting fools remain undecided."
* "No. I dislike the laziness of letting fools remain undecided."
"At Hohenreith, that dislike may become expensive."
"Then the Graf should have invited someone cheaper."
* "Enemies are merely people honest enough to stand in the right place."
"You speak as though conflict were housekeeping."
"It is. One discovers what belongs where."
-
The wheels strike a curve. The compartment leans. For a moment the two of you are held in the same narrow imbalance.
-> viktor_explains_orders
@@ -596,27 +940,55 @@ No one has written the word daughter.
Yet the omissions arrange themselves around the page like furniture around a corpse.
"There is another instruction," you say.
* "There is another instruction."
Viktor does not ask how you know.
Viktor does not ask how you know.
"There is always another instruction," he says.
"There is always another instruction," he says.
* "Your version is shorter than your silence. That means there is another instruction."
~ detective += 1
~ viktor_trust += 1
Viktor does not ask how you know.
"For you."
"There is always another instruction," he says.
"Yes."
* "How touching. Vienna trusts us both so little it had to divide the distrust."
~ eccentric += 1
~ viktor_suspicion += 1
Viktor does not ask how you know.
"Concerning me?"
"There is always another instruction," he says.
"Partly."
-
* "For you."
"Yes."
* "Concerning me."
"Partly."
* "Concerning whether I am fraud, fool, or useful animal."
~ viktor_suspicion += 1
"Partly," he says, and this time the honesty has a blade in it.
-
The train begins to slow. The rhythm changes first in the floor, then in the window, then in the body. Houses gather beside the line. A station roof appears between drifting smoke and the dark combs of forested slopes. #sfx[steam-whistle.ogg]
"Then I shall try to be worth the ink," you say.
* "Then I shall try to be worth the ink."
"I sincerely hope so."
"I sincerely hope so."
* "Then I shall disappoint the instruction as creatively as circumstances permit."
~ eccentric += 1
"I sincerely hope you do not."
You cannot decide whether it is an insult, a prayer, or his first honest sentence.
* "Then keep your second instruction, Herr Nowak. I prefer primary sources."
~ detective += 1
"A preference not always granted in imperial service."
-
You cannot decide whether his answer is an insult, a prayer, or his first honest sentence.
-> railway_station
@@ -626,7 +998,41 @@ The station is small enough that the train seems briefly embarrassed to stop the
A porter in a cap too large for him hurries along the platform. A woman with a basket steps back from the steam as if from an animal. Somewhere beyond the station building, a cart horse stamps at frozen mud. The signboard gives the place a name you have seen in the timetable but will not remember with affection.
Your luggage descends in stages: trunk, hatbox, smaller travelling case, dispatch case, folded rug, a narrow black case whose contents would embarrass both a priest and a conjurer if either searched it without imagination. Viktor oversees the transfer with clipped civility. He does not carry like a servant. He directs like a man pretending not to command.
Your luggage descends in stages.
* [A disciplined official set: trunk, dispatch case, hatbox, and black séance case.]
~ baggage_style = "official"
First comes a sober travelling trunk with brass corners dulled by use, then a dispatch case, then a hatbox, then the narrow black case whose contents would embarrass both a priest and a conjurer if either searched it without imagination.
~ detective += 1
-> station_luggage_common
* [An elegant noblewoman's luggage: too correct to be accidental.]
~ baggage_style = "elegant"
First comes a large trunk in dark leather, then a second smaller one for linen, then a round hatbox, a fitted toilette case, and a reticule kept too close to your hand for any porter to misunderstand its importance.
~ class_confidence += 1
-> station_luggage_common
* [A performers luggage: harmless on top, less harmless beneath.]
~ baggage_style = "performer"
First comes a respectable trunk, then a hatbox, then a travelling case of gloves, veils, ribbons, calling cards, and the small objects by which a room may be persuaded to believe in forces already present.
~ medium_reputation += 1
-> station_luggage_common
* [A practical assortment that betrays too much preparation.]
~ baggage_style = "practical"
First comes a battered trunk reinforced at the corners, then a leather case with notebooks, pencils, folded maps, spare gloves, a hand-lamp, and enough small necessities to offend anyone who prefers women decorative.
~ detective += 1
-> station_luggage_common
* [An excessive pile that makes concealment impossible.]
~ baggage_style = "excessive"
First comes one trunk, then another, then a hatbox, then a rug, then a dressing case, then the narrow black case, then a smaller parcel you had forgotten had survived packing. By the end even Viktor looks faintly outnumbered.
~ careless += 1
-> station_luggage_common
=== station_luggage_common ===
Viktor oversees the transfer with clipped civility. He does not carry like a servant. He directs like a man pretending not to command.
The coach from Hohenreith waits beyond the station yard: dark green paint, black wheels, the comital crest discreetly worn on the door, and two horses already restless beneath harness. The driver removes his hat when he sees you. Not too deeply. Deep enough for rank, not deep enough for reverence. #sfx[horse-neigh.ogg]
@@ -694,72 +1100,96 @@ A shrine, perhaps. A boundary marker. A figure. The coach has passed before your
Viktor has turned slightly toward the same slope.
"Did you see something?" he asks.
"Did you see something?"
* ["A woman in the wood, perhaps. Or a stone that wanted to be one."] #route:eccentric #statue_hint
* "A woman in the wood, perhaps. Or a stone that wanted to be one." #route:eccentric #statue_hint
~ eccentric += 1
~ viktor_suspicion += 1
He studies the passing trees.
"A local shrine?"
"If it is a shrine, it has not been loved recently."
* "If it is a shrine, it has not been loved recently."
"You speak as if stones notice neglect."
"Do soldiers not?"
He does not answer.
* "No. Shrines face the faithful. That thing was listening sideways."
~ supernatural_exposure += 1
Viktor's hand rests on the coach strap, still and ready.
-
-> coach_nears_village
* ["A marker. I would like to know where that path leads."] #route:detective #statue_hint
* "A marker. I would like to know where that path leads." #route:detective #statue_hint
~ detective += 1
~ viktor_trust += 1
"You saw a path?"
"Not clearly. Enough to ask later."
* "Not clearly. Enough to ask later."
Viktor looks back through the small rear window. The bend has already erased the slope.
"Ask carefully. Places people fail to mention are often more informative than those they recommend."
* "Only a suggestion of one. If it exists, someone maintains the absence of it."
~ detective += 1
"You make absences sound expensive."
"They usually are."
-
-> coach_nears_village
* ["Only trees. The sort that make one grateful for gentlemen with revolvers."] #route:careless
* "Only trees. The sort that make one grateful for gentlemen with revolvers." #route:careless
~ careless += 1
~ viktor_relation = "dependence"
His expression darkens by one official degree.
"A revolver is a poor instrument against trees."
"Then I shall rely on your conversation to intimidate them."
* "Then I shall rely on your conversation to intimidate them."
The driver pretends not to hear. His shoulders, however, hear everything.
* "How unfortunate. You seemed so professionally reassuring."
~ lover += 1
"I prefer enemies that identify themselves."
-
-> coach_nears_village
* ["Would you believe me if I said I had?"] #route:lover
* "Would you believe me if I said I had?" #route:lover
~ lover += 1
~ viktor_suspicion += 1
"That would depend on what advantage you expected from the answer."
"Herr Nowak. You wound me."
* "Herr Nowak. You wound me."
"Not yet."
It is the first thing he has said all day that almost sounds like flirtation, though perhaps only because danger has a talent for borrowing warmer clothes.
* "Then watch the slope, not my intentions. One of them may be useful."
~ viktor_trust += 1
He obeys without admitting that he has done so.
-
-> coach_nears_village
* ["No." ] #route:sapphic
* "No." #route:sapphic
~ sapphic += 1
The denial is too quick, and you both hear it.
You are not thinking of the stone now. You are thinking of the young woman waiting somewhere ahead: the Graf's daughter, the reason carefully not written into the memorandum, the stranger whose household has summoned you under a title both absurd and useful.
* "It was only shadow."
If this place keeps women in stone, you think, what does it do to them in houses?
* "Or if I did, I prefer not to have it explained before I understand why it matters."
~ detective += 1
If this place keeps women in stone, you think, what does it do to them in houses?
-
-> coach_nears_village
=== coach_nears_village ===
@@ -786,6 +1216,28 @@ Beside you, Viktor lowers his voice.
"Remember: at Hohenreith, every courtesy will mean something. Here, every silence will."
* "Then we are already being received."
~ detective += 1
"Yes," he says. "And examined."
* "You make it sound as if the village outranks the Graf."
~ eccentric += 1
"No," Viktor says. "Only as if it may have survived more than one."
* "How fortunate that I packed several silences."
~ lover += 1
His mouth almost moves. "Use the plainest one first."
* "I dislike being watched by people who will not introduce themselves."
~ careless += 1
"That," he says, "is unlikely to improve today."
* "If Amalia has lived under this gaze all her life, I begin to understand why they sent for ghosts."
~ sapphic += 1
Viktor glances at you, but whatever answer he considers, he keeps it behind his teeth.
-
The horses draw the coach past the churchyard wall. Above it, on the old plaster beside the gate, a faded painted woman looks down from beneath a flaking blue mantle. Her hands are folded in prayer. Her eyes, damaged by weather, no longer point in the same direction.
For one breath, as the wheels pass over a buried runnel of water, the painted face seems less like the Holy Mother than like a mask put on something that had been waiting longer.
File diff suppressed because one or more lines are too long
+18 -2
View File
@@ -83,11 +83,27 @@ class InkEngine {
if (!this.story) {
throw new Error('No active Ink story to save');
}
return this.story.state.toJson();
return JSON.stringify({
inkState: this.story.state.toJson(),
nextTurnId: this.nextTurnId,
});
}
loadGame(savedState) {
this.story = this.loadStory();
this.story.state.LoadJson(savedState);
let inkState = savedState;
try {
const parsed = JSON.parse(savedState);
if (parsed && typeof parsed.inkState === 'string') {
inkState = parsed.inkState;
if (Number.isInteger(parsed.nextTurnId)) {
this.nextTurnId = Math.max(1, parsed.nextTurnId);
}
}
}
catch {
// Backward compatibility with raw Ink state JSON.
}
this.story.state.LoadJson(inkState);
return this.continueStory();
}
loadStory() {
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -138,11 +138,12 @@ async function handleGameApi(socket, method, args) {
case 'loadGame':
case 'loadGame()': {
const slot = normalizeSaveSlot(args[0]);
if (!slots.has(slot)) {
const browserSave = typeof args[1] === 'string' ? args[1] : null;
if (!browserSave && !slots.has(slot)) {
return { success: false, error: 'missing_save', result: false };
}
const engine = getOrCreateEngine(socket.id);
socket.emit('narrativeResponse', engine.loadGame(slots.get(slot)));
socket.emit('narrativeResponse', engine.loadGame(browserSave || slots.get(slot)));
socket.emit('gameLoaded', { slot });
return { success: true, result: true, running: true, slot };
}
@@ -153,9 +154,10 @@ async function handleGameApi(socket, method, args) {
return { success: false, error: 'game_not_running', result: false };
}
const slot = normalizeSaveSlot(args[0]);
slots.set(slot, engine.saveGame());
const savedState = engine.saveGame();
slots.set(slot, savedState);
socket.emit('gameSaved', { slot });
return { success: true, result: true, slot };
return { success: true, result: true, slot, savedState };
}
case 'hasSaveGame':
case 'hasSaveGame()': {
+1 -1
View File
File diff suppressed because one or more lines are too long
+34
View File
@@ -421,6 +421,11 @@ ol.choice {
overflow-anchor: none;
}
.story-block-archiving {
opacity: 0;
transition: opacity 320ms ease;
}
.story-image-block {
box-sizing: border-box;
margin: 0 auto;
@@ -484,9 +489,38 @@ ol.choice {
scroll-behavior: smooth;
overscroll-behavior: contain;
overflow-anchor: none;
scrollbar-width: none;
/* transform: translateX(-1%) translateY(2%) rotateX(0deg) rotateY(-1deg) rotateZ(0deg); */
}
#page_right::-webkit-scrollbar {
display: none;
}
#story_scrollbar {
position: sticky;
float: right;
top: 0.4rem;
width: 0.22rem;
height: calc(100% - 0.8rem);
margin-right: -1.5rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.12);
z-index: 12;
pointer-events: none;
}
#story_scrollbar_thumb {
position: absolute;
left: 0;
right: 0;
top: 0;
min-height: 1rem;
border-radius: inherit;
background: rgba(0, 0, 0, 0.72);
transition: top 260ms ease, height 260ms ease;
}
/* ===== Scrollbar CSS ===== */
/* Firefox */
+36 -4
View File
@@ -9,7 +9,7 @@ class GameLoopModule extends BaseModule {
super('game-loop', 'Game Loop');
// Dependencies
this.dependencies = ['ui-controller', 'socket-client', 'text-buffer', 'sentence-queue', 'playback-coordinator', 'animation-queue', 'audio-manager', 'tts-factory', 'ui-input-handler'];
this.dependencies = ['ui-controller', 'socket-client', 'text-buffer', 'sentence-queue', 'playback-coordinator', 'animation-queue', 'audio-manager', 'tts-factory', 'ui-input-handler', 'story-history'];
// Game state
this.gameState = {
@@ -30,6 +30,7 @@ class GameLoopModule extends BaseModule {
'updateGameState',
'updateUIState',
'refreshGameApiState',
'hasSaveGame',
'requestStartGame',
'requestSaveGame',
'requestLoadGame',
@@ -136,7 +137,7 @@ class GameLoopModule extends BaseModule {
const [running, hasSave] = await Promise.all([
socketClient.isGameRunning(),
socketClient.hasSaveGame(1)
this.hasSaveGame(1)
]);
this.gameState.started = Boolean(running?.result);
@@ -191,6 +192,10 @@ class GameLoopModule extends BaseModule {
if (!socketClient) return;
await this.resetClientPlaybackAndDisplay();
const storyHistory = this.getModule('story-history');
if (storyHistory && typeof storyHistory.startNewGame === 'function') {
await storyHistory.startNewGame();
}
const response = await socketClient.newGame();
if (!response?.success) {
console.error('GameLoop: newGame failed', response);
@@ -211,6 +216,12 @@ class GameLoopModule extends BaseModule {
const response = await socketClient.saveGame(1);
if (response?.success) {
const storyHistory = this.getModule('story-history');
if (storyHistory && typeof storyHistory.saveSlot === 'function') {
await storyHistory.saveSlot(1, {
inkState: response.savedState || null
});
}
this.gameState.canLoad = true;
this.updateUIState();
}
@@ -223,7 +234,11 @@ class GameLoopModule extends BaseModule {
const socketClient = this.getModule('socket-client');
if (!socketClient) return;
const hasSave = await socketClient.hasSaveGame(1);
const storyHistory = this.getModule('story-history');
const browserSave = storyHistory && typeof storyHistory.loadSlot === 'function'
? await storyHistory.loadSlot(1)
: null;
const hasSave = browserSave ? { result: true } : await socketClient.hasSaveGame(1);
if (!hasSave?.result) {
this.gameState.canLoad = false;
this.updateUIState();
@@ -231,7 +246,14 @@ class GameLoopModule extends BaseModule {
}
await this.resetClientPlaybackAndDisplay();
const response = await socketClient.loadGame(1);
if (browserSave?.gameId && storyHistory?.setCurrentGame) {
storyHistory.setCurrentGame(browserSave.gameId, browserSave.latestBlockId || 0);
}
const uiController = this.getModule('ui-controller');
if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
await uiController.displayHandler.restoreFromHistory(browserSave);
}
const response = await socketClient.loadGame(1, browserSave?.inkState || null);
if (response?.success) {
this.gameState.started = true;
this.gameState.canSave = true;
@@ -240,6 +262,16 @@ class GameLoopModule extends BaseModule {
}
}
async hasSaveGame(slot = 1) {
const storyHistory = this.getModule('story-history');
if (storyHistory && typeof storyHistory.hasSaveSlot === 'function') {
const hasBrowserSave = await storyHistory.hasSaveSlot(slot);
if (hasBrowserSave) return { success: true, result: true, slot };
}
const socketClient = this.getModule('socket-client');
return socketClient?.hasSaveGame ? socketClient.hasSaveGame(slot) : { success: false, result: false };
}
async resetClientPlaybackAndDisplay() {
const playbackCoordinator = this.getModule('playback-coordinator');
if (playbackCoordinator && typeof playbackCoordinator.stop === 'function') {
+1
View File
@@ -103,6 +103,7 @@ const ModuleLoader = (function() {
// Core functionality modules
{ id: 'persistence-manager', script: '/js/persistence-manager-module.js', weight: 12 },
{ id: 'localization', script: '/js/localization-module.js', weight: 12 },
{ id: 'story-history', script: '/js/story-history-module.js', weight: 8 },
{ id: 'game-config', script: '/js/game-config-module.js', weight: 8 },
{ id: 'text-processor', script: '/js/text-processor-module.js', weight: 15 },
{ id: 'markup-parser', script: '/js/markup-parser-module.js', weight: 5 },
+25 -1
View File
@@ -21,6 +21,8 @@ class SentenceQueueModule extends BaseModule {
this.prefetchingCache = new Map();
this.activeImageWrap = null;
this.autoplay = true;
this.inputMode = 'text';
this.lastContinueAt = 0;
// Bind methods
this.bindMethods([
@@ -79,6 +81,14 @@ class SentenceQueueModule extends BaseModule {
this.autoplay = value !== false;
}
});
this.addEventListener(document, 'story:input-mode', (event) => {
this.inputMode = ['text', 'choice', 'end'].includes(event.detail) ? event.detail : 'text';
});
this.addEventListener(document, 'ui:command', (event) => {
if (event.detail?.type === 'continue') {
this.lastContinueAt = performance.now();
}
});
return true;
} catch (error) {
console.error("Error initializing Sentence Queue:", error);
@@ -139,6 +149,7 @@ class SentenceQueueModule extends BaseModule {
if (this.onSentenceReadyCallback) {
await new Promise(resolve => {
sentence.onComplete = resolve;
sentence.playbackStartedAt = performance.now();
this.onSentenceReadyCallback(sentence, resolve);
});
}
@@ -148,7 +159,7 @@ class SentenceQueueModule extends BaseModule {
await this.waitForSkippableMediaPause(mediaPauseSeconds, sentence.kind, sentence.id);
}
if (sentence.kind === 'paragraph' && !this.shouldAutoplay()) {
if (this.shouldPauseAfterSentence(sentence)) {
await this.waitForManualContinue(sentence.id);
}
@@ -495,6 +506,19 @@ class SentenceQueueModule extends BaseModule {
}
}
shouldPauseAfterSentence(sentence) {
if (sentence.kind !== 'paragraph' || this.shouldAutoplay()) {
return false;
}
if (this.lastContinueAt >= (sentence.playbackStartedAt || 0)) {
return false;
}
if (this.sentenceQueue.length <= 1 && this.inputMode === 'choice') {
return false;
}
return this.sentenceQueue.length > 1;
}
shouldAutoplay() {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
+3 -3
View File
@@ -131,7 +131,7 @@ class SocketClientModule extends BaseModule {
// Create Socket.IO connection (will automatically use /socket.io endpoint)
this.socket = window.io(socketUrl, {
reconnection: false, // We handle reconnection ourselves
transports: ['websocket', 'polling'] // Prefer WebSocket
transports: ['polling', 'websocket']
});
this.socket.on('connect', () => {
@@ -573,8 +573,8 @@ class SocketClientModule extends BaseModule {
return this.callGameApi('newGame', []);
}
loadGame(slot = 1) {
return this.callGameApi('loadGame', [slot]);
loadGame(slot = 1, savedState = null) {
return this.callGameApi('loadGame', savedState ? [slot, savedState] : [slot]);
}
saveGame(slot = 1) {
+189
View File
@@ -0,0 +1,189 @@
/**
* Story History Module
* Stores rendered story blocks in IndexedDB and keeps only a short live window
* in the page DOM.
*/
import { BaseModule } from './base-module.js';
class StoryHistoryModule extends BaseModule {
constructor() {
super('story-history', 'Story History');
this.dependencies = ['persistence-manager'];
this.dbName = 'ttsAudioCacheDB';
this.dbVersion = 2;
this.historyStore = 'storyHistoryStore';
this.saveStore = 'storySaveStore';
this.db = null;
this.currentGameId = null;
this.nextBlockId = 1;
this.visibleLimit = 20;
this.bindMethods([
'initialize',
'openDB',
'startNewGame',
'setCurrentGame',
'recordBlock',
'saveSlot',
'loadSlot',
'hasSaveSlot',
'getSaveSlots',
'getBlocks',
'clearGame',
'tx'
]);
}
async initialize() {
await this.openDB();
this.reportProgress(100, 'Story history ready');
return true;
}
openDB() {
if (this.db) return Promise.resolve(this.db);
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.historyStore)) {
const historyStore = db.createObjectStore(this.historyStore, { keyPath: 'key' });
historyStore.createIndex('gameId', 'gameId', { unique: false });
historyStore.createIndex('gameOrder', ['gameId', 'blockId'], { unique: true });
}
if (!db.objectStoreNames.contains(this.saveStore)) {
db.createObjectStore(this.saveStore, { keyPath: 'slot' });
}
};
});
}
tx(storeName, mode = 'readonly') {
return this.db.transaction(storeName, mode).objectStore(storeName);
}
async startNewGame() {
const gameId = `game-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this.currentGameId = gameId;
this.nextBlockId = 1;
const persistenceManager = this.getModule('persistence-manager');
persistenceManager?.updatePreference?.('app', 'currentGameId', gameId);
return gameId;
}
setCurrentGame(gameId, latestBlockId = 0) {
this.currentGameId = gameId || this.currentGameId;
this.nextBlockId = Math.max(1, Number(latestBlockId || 0) + 1);
}
recordBlock(block) {
if (!this.db || !this.currentGameId || !block) return Promise.resolve(null);
const blockId = this.nextBlockId++;
const record = {
...block,
key: `${this.currentGameId}:${blockId}`,
gameId: this.currentGameId,
blockId,
createdAt: Date.now()
};
return new Promise((resolve, reject) => {
const request = this.tx(this.historyStore, 'readwrite').put(record);
request.onsuccess = () => resolve(record);
request.onerror = () => reject(request.error);
});
}
saveSlot(slot = 1, saveData = {}) {
if (!this.db) return Promise.resolve(false);
const record = {
slot,
...saveData,
gameId: saveData.gameId || this.currentGameId,
latestBlockId: Math.max(0, this.nextBlockId - 1),
savedAt: Date.now()
};
return new Promise((resolve, reject) => {
const request = this.tx(this.saveStore, 'readwrite').put(record);
request.onsuccess = () => resolve(record);
request.onerror = () => reject(request.error);
});
}
loadSlot(slot = 1) {
if (!this.db) return Promise.resolve(null);
return new Promise((resolve, reject) => {
const request = this.tx(this.saveStore).get(slot);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
async hasSaveSlot(slot = 1) {
return Boolean(await this.loadSlot(slot));
}
getSaveSlots() {
if (!this.db) return Promise.resolve([]);
return new Promise((resolve, reject) => {
const request = this.tx(this.saveStore).getAllKeys();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
getBlocks(gameId = this.currentGameId, limit = this.visibleLimit, beforeBlockId = Infinity) {
if (!this.db || !gameId) return Promise.resolve([]);
return new Promise((resolve, reject) => {
const blocks = [];
const index = this.tx(this.historyStore).index('gameOrder');
const upper = Number.isFinite(beforeBlockId) ? beforeBlockId : Number.MAX_SAFE_INTEGER;
const range = IDBKeyRange.bound([gameId, 0], [gameId, upper], false, true);
const request = index.openCursor(range, 'prev');
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || blocks.length >= limit) {
resolve(blocks.reverse());
return;
}
blocks.push(cursor.value);
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
clearGame(gameId = this.currentGameId) {
if (!this.db || !gameId) return Promise.resolve();
return new Promise((resolve, reject) => {
const store = this.tx(this.historyStore, 'readwrite');
const index = store.index('gameId');
const request = index.openCursor(IDBKeyRange.only(gameId));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve();
return;
}
cursor.delete();
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
}
const storyHistory = new StoryHistoryModule();
export { storyHistory as StoryHistory };
if (window.moduleRegistry) {
window.moduleRegistry.register(storyHistory);
}
window.StoryHistory = storyHistory;
+11 -1
View File
@@ -32,7 +32,7 @@ class TTSFactoryModule extends BaseModule {
this.db = null; // Will hold the DB connection
this.dbName = 'ttsAudioCacheDB';
this.storeName = 'audioCacheStore';
this.dbVersion = 1;
this.dbVersion = 2;
this.currentCacheSize = 0; // Track current size in bytes
this.maxCacheSizeBytes = 100 * 1024 * 1024; // 100 MB by default
this.cacheInitialized = false;
@@ -1537,6 +1537,16 @@ class TTSFactoryModule extends BaseModule {
console.log("Created 'size' index.");
}
}
if (!db.objectStoreNames.contains('storyHistoryStore')) {
const historyStore = db.createObjectStore('storyHistoryStore', { keyPath: 'key' });
historyStore.createIndex('gameId', 'gameId', { unique: false });
historyStore.createIndex('gameOrder', ['gameId', 'blockId'], { unique: true });
console.log("Object store 'storyHistoryStore' created.");
}
if (!db.objectStoreNames.contains('storySaveStore')) {
db.createObjectStore('storySaveStore', { keyPath: 'slot' });
console.log("Object store 'storySaveStore' created.");
}
};
});
}
+163 -5
View File
@@ -9,7 +9,7 @@ class UIDisplayHandlerModule extends BaseModule {
super('ui-display-handler', 'UI Display Handler');
// Module dependencies
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization'];
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue'];
// DOM elements
this.container = null;
@@ -20,6 +20,7 @@ class UIDisplayHandlerModule extends BaseModule {
this.resizeTimer = null;
this.storyResizeObserver = null;
this.lastStoryMetrics = null;
this.visibleBlockLimit = 20;
// Resources to preload
this.cssPath = '/css/style.css';
@@ -35,6 +36,12 @@ class UIDisplayHandlerModule extends BaseModule {
'applyTranslations',
'displayText',
'renderSentence',
'recordRenderedItem',
'trimVisibleBlocks',
'restoreFromHistory',
'renderStoredItem',
'loadPreviousHistoryPage',
'updateStoryScrollbar',
'handleDeferredMediaBlock',
'renderImageBlock',
'calculateImageMetrics',
@@ -76,6 +83,7 @@ class UIDisplayHandlerModule extends BaseModule {
this.playbackCoordinator = this.getModule('playback-coordinator');
this.gameConfig = this.getModule('game-config');
this.localization = this.getModule('localization');
this.storyHistory = this.getModule('story-history');
this.reportProgress(50, "Initializing display containers");
@@ -97,6 +105,14 @@ class UIDisplayHandlerModule extends BaseModule {
this.addEventListener(document, 'story:scroll-to-turn', (event) => {
this.scrollToTurn(event.detail?.turnId);
});
this.addEventListener(document, 'story:history-updated', (event) => {
this.updateStoryScrollbar(event.detail || {});
});
this.addEventListener(document, 'wheel', (event) => {
if (event.target?.closest?.('#page_right') && event.deltaY < 0 && this.pageRight?.scrollTop <= 2) {
this.loadPreviousHistoryPage();
}
}, { passive: true });
this.addEventListener(document, 'story:process-state', (event) => {
const state = event.detail?.state || 'ready';
const remark = document.getElementById('remark_text');
@@ -294,6 +310,12 @@ class UIDisplayHandlerModule extends BaseModule {
this.pageRight.id = 'page_right';
bookContainer.appendChild(this.pageRight);
}
if (!document.getElementById('story_scrollbar')) {
const storyScrollbar = document.createElement('div');
storyScrollbar.id = 'story_scrollbar';
storyScrollbar.innerHTML = '<div id="story_scrollbar_thumb"></div>';
this.pageRight.appendChild(storyScrollbar);
}
// Create or find story container
this.container = document.getElementById('story');
@@ -478,7 +500,7 @@ class UIDisplayHandlerModule extends BaseModule {
// Store element reference in sentence
sentence.element = paragraphElement;
this.renderedItems.push({
await this.recordRenderedItem({
type: sentence.kind === 'heading' ? 'heading' : 'paragraph',
id: sentence.id,
turnId: sentence.turnId ?? null,
@@ -493,6 +515,7 @@ class UIDisplayHandlerModule extends BaseModule {
paragraphIndex: sentence.paragraphIndex
}
});
await this.trimVisibleBlocks();
this.scrollStoryToEnd(true);
@@ -591,6 +614,137 @@ class UIDisplayHandlerModule extends BaseModule {
});
}
async restoreFromHistory(saveRecord = {}) {
if (!this.paragraphContainer || !this.storyHistory || !saveRecord?.gameId) return;
const sentenceQueue = this.getModule('sentence-queue');
if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') return;
const blocks = await this.storyHistory.getBlocks(
saveRecord.gameId,
this.visibleBlockLimit,
(saveRecord.latestBlockId || Number.MAX_SAFE_INTEGER) + 1
);
this.paragraphContainer.innerHTML = '';
this.renderedItems = [];
for (const item of blocks) {
await this.renderStoredItem(item);
}
this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || blocks.at(-1)?.blockId || 1 });
this.scrollStoryToEnd(false);
}
async renderStoredItem(item) {
const sentenceQueue = this.getModule('sentence-queue');
if (!sentenceQueue) return null;
this.renderedItems.push(item);
if (item.type === 'image') {
const imageLayout = typeof sentenceQueue.prepareImageLayout === 'function'
? await sentenceQueue.prepareImageLayout(item.metadata || {})
: null;
const imageElement = this.renderImageBlock({
...(item.metadata || {}),
imageLayout: imageLayout || item.metadata?.imageLayout
}, false);
if (imageElement && item.blockId != null) imageElement.dataset.storyBlockId = String(item.blockId);
return imageElement;
}
if (item.type !== 'heading' && item.type !== 'paragraph') return null;
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
const element = this.layoutRenderer.renderParagraph(layout, { id: item.id });
if (item.turnId != null) {
element.dataset.turnId = String(item.turnId);
element.classList.add('story-turn-block');
}
if (item.blockId != null) element.dataset.storyBlockId = String(item.blockId);
element.querySelectorAll('.word').forEach(word => {
word.style.transition = 'none';
word.style.animation = 'none';
word.style.visibility = 'visible';
word.style.opacity = '1';
word.style.transform = 'translateY(0)';
word.style.clipPath = 'inset(0 0 0 0)';
});
this.paragraphContainer.appendChild(element);
return element;
}
async loadPreviousHistoryPage() {
if (!this.storyHistory || !this.paragraphContainer || this.loadingHistoryPage) return;
const firstBlock = this.paragraphContainer.querySelector('[data-story-block-id]');
const beforeBlockId = Number(firstBlock?.dataset?.storyBlockId || 0);
if (!beforeBlockId || beforeBlockId <= 1) return;
this.loadingHistoryPage = true;
try {
const blocks = await this.storyHistory.getBlocks(
this.storyHistory.currentGameId,
this.visibleBlockLimit,
beforeBlockId
);
if (!blocks.length) return;
this.paragraphContainer.innerHTML = '';
this.renderedItems = [];
for (const item of blocks) {
await this.renderStoredItem(item);
}
this.pageRight.scrollTop = 0;
this.updateStoryScrollbar({ latestBlockId: this.storyHistory.nextBlockId - 1 });
} finally {
this.loadingHistoryPage = false;
}
}
async recordRenderedItem(item) {
this.renderedItems.push(item);
if (this.storyHistory && typeof this.storyHistory.recordBlock === 'function') {
try {
const record = await this.storyHistory.recordBlock(item);
if (record && item.id) {
item.blockId = record.blockId;
item.gameId = record.gameId;
const element = document.getElementById(item.id);
if (element) element.dataset.storyBlockId = String(record.blockId);
}
document.dispatchEvent(new CustomEvent('story:history-updated', {
detail: {
gameId: record?.gameId || null,
latestBlockId: record?.blockId || null
}
}));
} catch (error) {
console.warn('UIDisplayHandler: Failed to store story history item:', error);
}
}
}
updateStoryScrollbar(detail = {}) {
const thumb = document.getElementById('story_scrollbar_thumb');
if (!thumb) return;
const latest = Math.max(1, Number(detail.latestBlockId || this.storyHistory?.nextBlockId || 1));
const visible = Math.min(this.visibleBlockLimit, latest);
const heightPercent = Math.max(8, Math.min(100, (visible / latest) * 100));
const topPercent = latest <= visible ? 0 : 100 - heightPercent;
thumb.style.height = `${heightPercent}%`;
thumb.style.top = `${topPercent}%`;
}
async trimVisibleBlocks() {
if (!this.paragraphContainer) return;
const blocks = Array.from(this.paragraphContainer.querySelectorAll('.story-turn-block'));
const excess = blocks.length - this.visibleBlockLimit;
if (excess <= 0) return;
blocks.slice(0, excess).forEach(block => {
block.classList.add('story-block-archiving');
window.setTimeout(() => block.remove(), 360);
});
this.renderedItems = this.renderedItems.slice(Math.max(0, this.renderedItems.length - this.visibleBlockLimit));
}
animatePageScroll(targetTop, duration = 720) {
if (!this.pageRight) return;
if (!duration) {
@@ -662,14 +816,15 @@ class UIDisplayHandlerModule extends BaseModule {
}));
if (sentence.kind === 'image') {
const element = this.renderImageBlock(sentence.metadata || {}, true);
this.renderedItems.push({
const element = this.renderImageBlock({ ...(sentence.metadata || {}), id: sentence.id }, true);
await this.recordRenderedItem({
type: 'image',
id: sentence.id,
turnId: sentence.turnId ?? null,
text: '',
metadata: sentence.metadata || {}
metadata: { ...(sentence.metadata || {}), id: sentence.id }
});
await this.trimVisibleBlocks();
this.scrollStoryToEnd(true);
@@ -743,6 +898,9 @@ class UIDisplayHandlerModule extends BaseModule {
const metrics = metadata.imageLayout || this.calculateImageMetrics(metadata.size);
const figure = document.createElement('figure');
if (metadata.id) {
figure.id = metadata.id;
}
figure.className = [
'story-image-block',
`story-image-${metrics.size || 'landscape'}`,
+17 -2
View File
@@ -109,12 +109,27 @@ export class InkEngine {
if (!this.story) {
throw new Error('No active Ink story to save');
}
return this.story.state.toJson();
return JSON.stringify({
inkState: this.story.state.toJson(),
nextTurnId: this.nextTurnId,
});
}
loadGame(savedState: string): TurnResult {
this.story = this.loadStory();
this.story.state.LoadJson(savedState);
let inkState = savedState;
try {
const parsed = JSON.parse(savedState);
if (parsed && typeof parsed.inkState === 'string') {
inkState = parsed.inkState;
if (Number.isInteger(parsed.nextTurnId)) {
this.nextTurnId = Math.max(1, parsed.nextTurnId);
}
}
} catch {
// Backward compatibility with raw Ink state JSON.
}
this.story.state.LoadJson(inkState);
return this.continueStory();
}
+6 -4
View File
@@ -130,11 +130,12 @@ async function handleGameApi(
case 'loadGame':
case 'loadGame()': {
const slot = normalizeSaveSlot(args[0]);
if (!slots.has(slot)) {
const browserSave = typeof args[1] === 'string' ? args[1] : null;
if (!browserSave && !slots.has(slot)) {
return { success: false, error: 'missing_save', result: false };
}
const engine = getOrCreateEngine(socket.id);
socket.emit('narrativeResponse', engine.loadGame(slots.get(slot)!));
socket.emit('narrativeResponse', engine.loadGame(browserSave || slots.get(slot)!));
socket.emit('gameLoaded', { slot });
return { success: true, result: true, running: true, slot };
}
@@ -146,9 +147,10 @@ async function handleGameApi(
return { success: false, error: 'game_not_running', result: false };
}
const slot = normalizeSaveSlot(args[0]);
slots.set(slot, engine.saveGame());
const savedState = engine.saveGame();
slots.set(slot, savedState);
socket.emit('gameSaved', { slot });
return { success: true, result: true, slot };
return { success: true, result: true, slot, savedState };
}
case 'hasSaveGame':